From 506957349ca7447104722bc384cbbca336a0de72 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 08:49:36 -0700 Subject: [PATCH 001/112] =?UTF-8?q?Stage=200:=20addon=20scaffold=20?= =?UTF-8?q?=E2=80=94=20ocean=20gamemode=20with=20interstice=20world?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TRADEWINDS_SPEC.md (authoritative spec) and CLAUDE.md - GameModeAddon with overworld ocean + Nether-environment interstice, no End - Poseidon-style ocean generator with Stage 1 terrainScale mask hook - Settings with galaxy/travel/illegal-trade config skeleton - 22 unit tests green on patched MockBukkit (added worldgen/biome to patcher) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- .gitignore | 9 + CLAUDE.md | 76 + README.md | 20 + TESTING.md | 24 + TRADEWINDS_SPEC.md | 172 ++ docs/PROGRESS.md | 56 + pom.xml | 298 +++ scripts/build_patched_mockbukkit.py | 189 ++ scripts/ci_prep.sh | 39 + .../world/bentobox/tradewinds/Settings.java | 2028 +++++++++++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 181 ++ .../tradewinds/TradeWindsPladdon.java | 20 + .../generator/ChunkGeneratorWorld.java | 164 ++ .../generator/TradeWindsBiomeProvider.java | 43 + src/main/resources/addon.yml | 29 + src/main/resources/config.yml | 76 + src/main/resources/locales/en-US.yml | 19 + src/main/resources/plugin.yml | 9 + .../net/kyori/adventure/util/Buildable.java | 37 + .../PlainTextComponentProviderImpl.java | 56 + .../bentobox/tradewinds/CommonTestSetup.java | 301 +++ .../bentobox/tradewinds/SettingsTest.java | 85 + .../tradewinds/TestWorldSettings.java | 409 ++++ .../bentobox/tradewinds/TradeWindsTest.java | 213 ++ .../world/bentobox/tradewinds/WhiteBox.java | 26 + .../generator/ChunkGeneratorWorldTest.java | 208 ++ .../TradeWindsBiomeProviderTest.java | 65 + tradewinds-design-decisions.md | 81 + tradewinds-dev-plan.md | 100 + tradewinds-overview.md | 33 + 30 files changed, 5066 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 TESTING.md create mode 100644 TRADEWINDS_SPEC.md create mode 100644 docs/PROGRESS.md create mode 100644 pom.xml create mode 100755 scripts/build_patched_mockbukkit.py create mode 100755 scripts/ci_prep.sh create mode 100644 src/main/java/world/bentobox/tradewinds/Settings.java create mode 100644 src/main/java/world/bentobox/tradewinds/TradeWinds.java create mode 100644 src/main/java/world/bentobox/tradewinds/TradeWindsPladdon.java create mode 100644 src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java create mode 100644 src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java create mode 100644 src/main/resources/addon.yml create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/locales/en-US.yml create mode 100644 src/main/resources/plugin.yml create mode 100644 src/test/java/net/kyori/adventure/util/Buildable.java create mode 100644 src/test/java/org/mockbukkit/mockbukkit/adventure/PlainTextComponentProviderImpl.java create mode 100644 src/test/java/world/bentobox/tradewinds/CommonTestSetup.java create mode 100644 src/test/java/world/bentobox/tradewinds/SettingsTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/TestWorldSettings.java create mode 100644 src/test/java/world/bentobox/tradewinds/TradeWindsTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/WhiteBox.java create mode 100644 src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java create mode 100644 tradewinds-design-decisions.md create mode 100644 tradewinds-dev-plan.md create mode 100644 tradewinds-overview.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb2769a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +target/ +*.iml +.idea/ +.classpath +.project +.settings/ +.DS_Store +database/ +database_backup/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bfc3a27 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +Guidance for Claude Code working in this repository. + +## Commands + +```bash +mvn clean package # build (jar in target/) +mvn test # all tests +mvn test -Dtest=ClassName # one test class +``` + +Deploy for in-game testing: copy `target/TradeWinds-*-LOCAL.jar` to +`/Users/ben/Minecraft/26.2/plugins/BentoBox/addons/`. + +## Project + +**TradeWinds** is a BentoBox GameModeAddon for Paper **26.2**: an endless +procedurally generated ocean of NPC trading islands — buy low, sell high, +smuggle, hunt bounties, turn pirate. Requirements live in `TRADEWINDS_SPEC.md` +(the authoritative spec; read it first). Design rationale is in +`tradewinds-design-decisions.md`, the stage plan in `tradewinds-dev-plan.md`. +`docs/PROGRESS.md` records what is done and pitfalls hit; `TESTING.md` holds +the manual in-game test checklist per stage. Update both as features land. + +Load-bearing design rules (from the spec — breaking one is a bug): +trading transacts only against bundles/boat hold; everything downstream of the +galaxy seed is a pure function of (seed, position) with **no Bukkit imports** +(package `world.bentobox.tradewinds.galaxy`), unit-tested headlessly; no End +world ever; interstice re-engage is always free; police mobs never drop loot. + +## Environment (verified — do not "upgrade" blindly) + +- Paper API `26.2.build.40-alpha` (Java 25 bytecode: build with **JDK 25**, + compile at release 21), BentoBox `3.18.1` from `~/.m2`, test server runs + BentoBox 3.21.1-SNAPSHOT. +- Test server: `/Users/ben/Minecraft/26.2` — has Vault, PlaceholderAPI, + LuckPerms, Multiverse, Border addon, and other gamemodes (AcidIsland, + AOneBlock, Gusher…) — TradeWinds must coexist with all of them. +- `org.bukkit.Sound` is an **interface** now — no `valueOf`; resolve + config-supplied sound names via + `RegistryAccess.registryAccess().getRegistry(RegistryKey.SOUND_EVENT)`. + +## Testing quirks (important) + +MockBukkit has **no 26.2 release**; tests run against the locally patched jar +`org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:4.113.4-p262`, built by +`~/git/GushBlock/scripts/build_patched_mockbukkit.py` (rerun it if the version +disappears from `~/.m2`; prereq: unzip the paper-api jar to `/tmp/paperapi`). +Two class-level shims are copied into test sources (Adventure 4→5 breakage): +`org/mockbukkit/mockbukkit/adventure/PlainTextComponentProviderImpl` and +`net/kyori/adventure/util/Buildable`. Surefire needs the long `--add-opens` +argLine (already in pom.xml) or MockBukkit reflection dies on Java 25. +Force-init `org.bukkit.Tag.LEAVES` before static-mocking Bukkit (stale-mock +trap). Tests extend `world.bentobox.tradewinds.CommonTestSetup` (adapted from +Gusher/AOneBlock). Never leave stale files in `target/test-classes` when +renaming resources. + +## Reference repos (all local) + +- `~/git/bentobox` — BentoBox core source. +- `~/git/addon-acidisland` — GameModeAddon anatomy, ocean worlds, nether roof, + the dismount/teleport/re-seat boat pattern. +- `~/git/poseidon` — ocean generator (PerlinOctaveGenerator, sea floor noise, + BiomeProvider, BlockPopulator decoration). +- `~/git/GushBlock` — newest 26.2-era addon: pom, test bootstrap, patched + MockBukkit, `docs/API_VERIFICATION.md` (verified 26.2 API facts). +- `~/git/Boxed` — structure/jigsaw placement patterns (Stage 2). +- `~/git/Border` — border display addon (passable visual border, Stage 3). +- `~/git/bluebook` — economy prices: `BlueBook#getPriceEngine()`, + `PriceEngine.getPrice(ItemStack, worldName)` (Stage 4). + +Follow BentoBox conventions: `Config` + `@ConfigEntry`/`@StoreAt`, +`Database` + cache managers, `FlagListener`, `DefaultPlayerCommand` +/`DefaultAdminCommand`, locale keys under `tradewinds.`, every gameplay number +in config from the stage it's introduced — no hardcoded gameplay values. diff --git a/README.md b/README.md index e0beaf7..9d8df0c 100644 --- a/README.md +++ b/README.md @@ -1 +1,21 @@ # TradeWinds + +A BentoBox game mode of sea trading, smuggling, and piracy: an endless +procedurally generated ocean dotted with NPC trading islands. Start with a boat +and a single trading bundle; get rich buying low and selling high — honestly, +or by smuggling contraband past customs, hunting bounties, or outright piracy. + +Inspired by TradeWars 2002 and Elite, rebuilt in Minecraft terms. + +- Spec: [`TRADEWINDS_SPEC.md`](TRADEWINDS_SPEC.md) +- Progress: [`docs/PROGRESS.md`](docs/PROGRESS.md) +- Manual test checklists: [`TESTING.md`](TESTING.md) + +## Building + +```bash +mvn clean package # requires JDK 25; jar lands in target/ +``` + +Requires BentoBox 3.18.1+ on Paper 26.2. Drop the jar in +`plugins/BentoBox/addons/`. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..229628c --- /dev/null +++ b/TESTING.md @@ -0,0 +1,24 @@ +# TradeWinds — Manual In-Game Test Checklists + +Per-stage manual verification on the test server (`/Users/ben/Minecraft/26.2`). +Deploy: `cp target/TradeWinds-*-LOCAL.jar /Users/ben/Minecraft/26.2/plugins/BentoBox/addons/` +then restart the server. Automated coverage lives in `src/test`; this file is for +what only a live server can prove. + +## Stage 0 — Addon scaffold + +- [ ] Server starts with no errors/warnings from TradeWinds in the console. +- [ ] `bbox version` lists TradeWinds alongside the other gamemodes (AcidIsland, AOneBlock, Gusher…), state ENABLED. +- [ ] Worlds `tradewinds_world` and `tradewinds_world_nether` exist (`mv list` / console log). +- [ ] `/tw` (and `/tradewinds`) teleports the player to spawn in open ocean — water to the horizon, no land, no vanilla continents. +- [ ] Ocean floor exists (dive down: sand/sandstone floor roughly y25–y50, bedrock at bottom, no caves by default). +- [ ] Above-water world is air up to build height (no floating junk). +- [ ] The interstice is inaccessible: nether portals in `tradewinds_world` do not activate/link (build one and light it), and no command teleports there. +- [ ] Interstice world (teleport there as admin, e.g. `mv tp`): water sea over basalt/soul-sand floor, nether ambience. +- [ ] No `tradewinds_world_the_end` world is created. +- [ ] `/twadmin` responds (admin help). +- [ ] `addons/TradeWinds/config.yml` generated with all Stage 0 sections (galaxy, travel, illegal-trade, world). +- [ ] Restart the server: worlds reload, no duplicate-world or generator errors, chunks unchanged (fly the same area). +- [ ] Other gamemodes still work (create/visit an AcidIsland island). + +## Stage 1 — Seeded galaxy *(placeholder — filled in when Stage 1 lands)* diff --git a/TRADEWINDS_SPEC.md b/TRADEWINDS_SPEC.md new file mode 100644 index 0000000..eec23ab --- /dev/null +++ b/TRADEWINDS_SPEC.md @@ -0,0 +1,172 @@ +# TradeWinds — BentoBox Game Mode Specification + +**Name:** TradeWinds (decided — house style, one word, like BSkyBlock/AcidIsland; repo `TradeWinds` under BentoBoxWorld) +**Addon ID:** `TradeWinds` · **Package:** `world.bentobox.tradewinds` · **Commands:** `/tw` (admin: `/twadmin`) +**Target:** Paper 26.2 (Java 25 runtime, release-21 compile), BentoBox 3.18.1+ +**Author context:** Written for implementation via Claude Code by the BentoBox author. Assume full familiarity with GameModeAddon, WorldSettings, Blueprints, Flags, the BentoBox Database, and the AcidIsland/Poseidon codebases (closest architectural relatives). Companion docs: `tradewinds-overview.md` (pitch), `tradewinds-design-decisions.md` (rationale + open questions), `tradewinds-dev-plan.md` (stage plan). Where this spec and those docs disagree, this spec wins. + +--- + +## 1. Concept + +A sea-trading game mode: an endless procedurally generated ocean dotted with **NPC trading islands**. Players start with a boat and one trading bundle and get rich buying low and selling high — honestly, or by smuggling, bounty hunting, and piracy. Inspired by TradeWars 2002 and Elite, rebuilt in Minecraft terms. + +| Elite/TW2002 ingredient | TradeWinds equivalent | +|---|---| +| Sectors / systems | Seeded sparse trading islands in open ocean | +| Hyperspace jump + fuel | Boat **warp** between island borders, paid in hold-carried fuel | +| Misjump / interdiction | **Interstice**: warp failure drops you in a hostile Nether sea | +| Cargo hold expansion | Bundles → chest boat → shulker "cargo expanders" | +| Commodity market | BlueBook base prices × island type/biome/security modifiers | +| Police / legal status | Reputation bands, customs scans, police mobs, bounties | +| Player bases | Purchasable player islands in the open ocean | + +### Design principles (load-bearing — do not break) + +1. **The fuel/cargo tradeoff is the central mechanic.** Trading transacts **exclusively** against trading bundles and boat hold — never player inventory, ender chests, or carried shulkers. Any feature letting sellable goods bypass the hold breaks the game's spine. +2. **Risk symmetry between travel modes.** Warp: fuel cost, instant, interstice risk. Rowing: free, slow, lawless-ocean risk. Neither may become strictly dominant. +3. **Scans, not prices, balance contraband.** Sugar has infinite trivial supply; the customs-scan risk is the cost. Smuggling is a skill, not a tax. +4. **Crime pays you into danger.** Contraband sells only at less-safe islands; Fugitives are barred from safe-island markets. The richest criminals are structurally pushed into PvP space where bounty hunters lawfully operate. +5. **The seed is the world.** Island positions, existence, types, security bands, biomes, names, and route-graph edge costs are ALL pure deterministic functions of (seed, position) — unit-testable with no Bukkit dependency. Runtime randomness here breaks seed shareability. +6. **Generator-driven terrain, not pasted terrain.** Island landmass comes from the chunk generator (Poseidon-style noise × radial mask). Lazy generation is just chunk generation: no pop-in, no paste pacing. Blueprints/structures decorate; they never create the land. +7. **Scarcity by dimension removal.** No End world → shulker shells unobtainable → cargo expanders are a purchasable, cap-controlled money sink. Never add End access. + +## 2. World Model + +### 2.1 Worlds + +- **Overworld** `tradewinds_world`: endless ocean, AcidIsland/Poseidon-style generator (sea over noised ocean floor, **no acid**), islands from the placement engine (§2.2). Default sea height 70, sea floor ~25 (Poseidon numbers; config). +- **Interstice** `tradewinds_world_nether`: NETHER-environment world, styled as a hostile dark sea (lava-tinged sky, ocean of water — Nether environment gives the ambience; water block configurable). Same generator class, no islands. Registered as the gamemode's nether world with `netherIslands: false`; **no portals** — entry only via warp failure (Stage 5). Until then it must be inaccessible to players. +- **No End world, ever** (principle 7). `endGenerate: false` hardcoded default. + +### 2.2 Placement engine (pure, seeded) + +`GalaxyEngine` — plain Java, zero Bukkit imports, fully unit-tested: + +- **Input:** `galaxySeed` (long, config; shareable). All outputs are pure functions of (seed, cell/position). +- **Positions:** the ocean is divided into a coarse **spatial-hash grid** (cell size = `island-range × 2`, config). Each cell deterministically rolls (seeded by `hash(seed, cellX, cellZ)`) whether it hosts an island and where within the cell (jitter), then **rejection-samples against minimum separation** from neighbor cells' islands so protection zones never overlap and islands are never within sight of one another. Result: random-looking sparse scatter, deterministic, O(1) lookup of "which islands are near (x,z)". +- **Density**: base occupancy probability per cell, with a **density floor near spawn** (guaranteed starter cluster regardless of seed luck) and optional falloff outward. Proposed (adopt unless playtest says otherwise): density correlates with civilization — clusters are civilized, isolated islands anarchic; the map itself communicates risk. +- **Per-island attributes** (all from `hash(seed, islandPos, attributeSalt)`): + - **Type:** agricultural, fishing, industrial, mining, luxury, … (config-extensible list; drives market modifiers and blueprint set). + - **Security band:** EVE model — `SAFE`, `POLICED`, `FRONTIER`, `LAWLESS`, `ANARCHIC` (working names; config thresholds). Correlated with local density per above. + - **Biome:** whole-island, from a per-type weighted table. Frozen-ocean approaches are a feature (ice = fast boat lanes). + - **Name:** Elite-BBC-style procedural token-pair digraph generator, seeded — pronounceable, distinct. + - **Range / protection radius:** AcidIsland framing — range ~1000, protection ~400 (config). +- **Reserved space:** cells that rolled empty are candidate sites for purchased player islands (Stage 7). + +### 2.3 Chunk generator + +`ChunkGeneratorWorld` derived from Poseidon (`PerlinOctaveGenerator(worldSeed, 8)`, scale 1/30, NOISE_MAX-style amplitude): + +- Everywhere: ocean floor noise from sea floor, water up to sea height, air above. +- Near an island center (from `GalaxyEngine` — O(1) neighbor-cell lookup per chunk): terrain amplitude is multiplied by a **radial falloff mask** `m(d)` (1 at center → 0 at island's terrain radius), lifting land above sea level at the center and shelving into ocean. Pure ocean elsewhere. +- **Biome:** `BiomeProvider` returns the island's biome within its terrain radius (per column), ocean/air biomes elsewhere (Poseidon's y-split pattern for sea vs air). +- Vanilla decoration on (`shouldGenerateDecorations`), caves/structures config, mobs on. Interstice variant: NETHER environment, sea of water over basalt/blackstone-ish floor, nether roof (AcidIsland `makeNetherRoof` pattern). +- **Island registration:** on first generation of an island's center chunk (or first entry — listener on chunk load), register an **unowned BentoBox island** at the center with range/protection from config and flags per security band. Island name announced on entry (BentoBox island enter event → action bar/title). + +## 3. Travel + +### 3.1 Rowing + +Free. The border (Border addon in passable/visual mode, or protection-range visuals) does not block movement. Open ocean between islands is lawless: no protection flags apply (PvP live), no police. Ice lanes are fast. + +### 3.2 Warp + +- **Trigger:** player in a boat reaches an island's border (proximity trigger + action-bar prompt; packet-level "click the wall" rejected as needless complexity). Opens the **warp dialog** (Paper dialog API): charted islands listed with per-destination fuel cost; unaffordable/unreachable grayed out. +- **Route graph:** seeded per-edge costs; default = Euclidean distance × `fuel-per-block` multiplier; per-edge overrides in config allow cheap "warp lanes" and expensive frontiers without world regen. +- **Fuel:** value table (config): wood < coal/charcoal < coal block < lava bucket (non-stackable = deliberate tension). Consumed **from bundles/boat hold only** (principle 1). Empty buckets returned. +- **Execution** (AcidIsland `/ai` proven pattern): dismount → teleport player + boat cross-world-safe → re-seat. Arrival just inside destination border **on the bearing of the origin island**. Effects: nausea + blindness (durations config) + configurable minor damage ("warping hurts" — gates the under-equipped), portal particles + sound. +- **Charting:** dialog lists **charted** islands only. Chart by physically entering an island's range (free, slow); starter cluster pre-charted for new players. Buying chart data = post-MVP. Charting is the discovery layer over lazy generation. + +### 3.3 Interstice (warp failure) + +- Config failure chance (base; wanted players may get a higher rate — adopted from proposals). On failure: player + boat placed on interstice water **partway along the route** (seeded fraction ± jitter), 1–3 Ghasts spawned inbound. +- **Re-engaging the warp to the original destination is always free** — fuel was spent at engagement; failure is a detour, never a loss. This makes stranding impossible. Do not remove. +- Ghast-vs-boat combat intentionally survivable (fireballs battable). Interstice is a **shared** world (interdiction/ambush meta — very Elite); PvP rules there are an open question (§10). +- Stale entity cleanup: interstice mobs despawn on player exit / world empty. + +## 4. Cargo & Progression + +- **Start kit:** boat + 1 **trading bundle** (a bundle = one stack's worth of capacity, vanilla bundle rules). +- **Progression:** up to 3 bundles → chest boat → **cargo expanders**: lore-renamed shulker boxes, purchase-only (no End → no crafting), price ~doubling per unit, configurable cap. Expanders live in the chest boat's inventory; their contents count as hold. +- **Hold definition (canonical):** the player's trading bundles + the chest-boat inventory + expander contents. All trade and fuel transactions resolve against the hold only. +- **Boat ownership:** owner UUID in boat entity PDC from day one (theft/persistence/bounty questions hang off it). + +## 5. Economy + +- **BlueBook** (`~/git/bluebook`, addon name `BlueBook`) supplies base prices via `PriceEngine.getPrice(ItemStack, worldName)` (single price; TradeWinds derives buy/sell spread). Soft-depend; fail gracefully with a config fallback table if absent. +- **Per-island modifiers:** island type (farm sells food cheap, wants raw materials…), biome, security band (margins scale with danger), plus per-island **stock and price drift** persisted via the BentoBox Database (keyed by island). Everything in Minecraft is tradeable; players may sell found/crafted goods into the same market. +- **Trade GUI** at the dock (villager interaction or dock sign), transacting only against the hold (§4). +- **Vault** economy for balances; starter balance config. + +## 6. Contraband & Customs + +- **Master config gate** `illegal-trade.enabled` disables ALL illegal-goods mechanics (family servers). Default on. +- **Contraband:** sugar (config list; never called drugs anywhere — code, config, locale). **Villager "passengers"** in boats sellable at less-safe islands only (boating the villager is the player's problem — emergent). Gated separately (`illegal-trade.passenger-trade`). +- **Customs scan on every entry** into island space — rowed or warped. Chance per security band (SAFE scans often; ANARCHIC never). Per-island scan cooldown prevents re-entry scumming. +- **Detection = chase, not fine:** "customs patrol dispatched" — police launch from the island; ~400 blocks of water is the decision window: **flee** across the border, **fight**, or **jettison** (dumped items float and are lootable by anyone — emergent piracy). **Caught** = police land a hit or close to proximity radius while contraband aboard → confiscation + fine + rep loss. +- **Fleeing a detection flags you** at that island for a cooldown: re-entry skips the roll, police launch immediately. + +## 7. Reputation, Police, Bounties + +- **Single global score**, named bands: `UPSTANDING / CLEAN / OFFENDER / WANTED / FUGITIVE` (working). Proposed numbers (adopt as defaults, all config): scale −1000..+1000; hit villager −5, kill villager −25, kill police −15, kill innocent −100, scan-caught −30 + fine, passenger sale −20; decay +1 per 15 min clean active play toward zero; thresholds +250 / 0 / −200 / −500. +- **Recovery, three speeds:** slow clean-play decay (crimes shadow you for sessions); fines (capped — money buys you out of Wanted, not into virtue); positive acts (contracts/donations — post-MVP) above zero. Positive rep must pay: better prices, lower scan odds — Upstanding is a pursued status. +- **Wanted:** PvP damage override anywhere; growing bounty; police response at civilized islands. **Fugitive:** shoot-on-sight, barred from safe-island trading (principle 4). Killing a wanted player pays the bounty, no rep penalty — bounty hunting is lawful work. Bounty pays exactly once. +- **Bounty nameplate** (config-gated, default on): bounty displayed with the player's name via per-player scoreboard team suffix, only when bounty > 0. PlaceholderAPI placeholder exposed (`%tradewinds_bounty%` etc.) so TAB-style plugins can render instead — avoids conflicts. (Boats prevent sneaking: traveling pirates are always visible. Feature.) +- **Police by mobility:** **Phantoms pursue** (combustion cancelled or day patrols burn at dawn), **Guardians hold the sea perimeter** (area denial, never chase boats), **Iron Golems** take anyone who lands. All PDC-tagged, ticking `setTarget` reassertion task, **zero drops** (else wanted players become a crime-powered iron farm). Pursue within protection zone; break off when target crosses the visible border; despawn beyond island range or on chunk unload. +- **Per-band knobs:** scan chance AND response size — a mid band may scan rarely but respond hard. Route planning with contraband is map-reading skill. +- **Anti-bait guard:** innocent-kill penalty on direct kills only; prior damage from the victim exempts. Log edge cases; don't over-engineer pre-MVP. +- **Villager gossip** API hooked for organic price penalties at the offended island. + +## 8. Player Islands (Stage 7) + +Purchase command: sufficient balance → choose blueprint → placement at a valid reserved position (empty grid cell) respecting minimum separation from **all** islands (NPC and player). Standard BentoBox island/team management, protection, homes thereafter. AcidIsland-equivalent island life. + +## 9. Data Model (BentoBox Database objects) + +- `TWPlayerData` (by player UUID): reputation score, bounty, chart set (known island IDs), flags (per-island flee-flags with expiry), scan cooldowns, expander purchase count. +- `TWIslandData` (by island ID): galaxy cell coords, type, security band, biome, name, stock levels + price drift map, market state timestamps. +- `TWWorldData` (singleton): galaxy seed (authoritative copy; config seed used at first creation then persisted), route-edge overrides cache, bounty ledger totals. +- Boat ownership, police tags, loot tags: entity **PDC**, not database. +- Transient (in-memory): active chases, dialog sessions, pursuit tasks. + +## 10. Open Questions (running list — resolve before/at the flagged stage) + +1. **Poseidon mask internals** (Stage 1): mask multiplies noise amplitude vs. lifts a base height — prototype both, pick by coastline quality. +2. **Dock placement** (Stage 2): deterministic terraformed shelf at fixed bearing (lean) vs. jigsaw adaptive. +3. **Boat loss rules** (Stage 4/8): what drops on boat destruction/death? Insurance is the counterweight. Undecided. +4. **Interstice PvP** (Stage 5): is interdicting non-wanted players lawful or itself a rep crime? +5. **Scan/flag tuning** (Stage 6b): cooldown lengths, caught-proximity radius. +6. **Galaxies architecture** (post-MVP): multi-instance addon vs. world manager. Keep world references abstracted from day one. +7. **Villager shoreline safety** (Stage 2): keep villagers inland behind docks so drive-by raids don't trivialize police. +8. **Reputation numbers** (Stage 6a): defaults above are proposals; playtest-tune. + +## 11. BentoBox Integration Checklist + +- `TradeWinds extends GameModeAddon`; `addon.yml` api-version 3.18.0, icon `OAK_CHEST_BOAT`, softdepend `BlueBook, Border, Level, Challenges, Visit, Warps`. +- `Settings implements WorldSettings`, `@StoreAt(filename="config.yml", path="addons/TradeWinds")`, every gameplay number a `@ConfigEntry` from the stage it appears. Master gates: `illegal-trade.enabled`, bounty nameplate, interstice. +- Commands: `/tw` (`DefaultPlayerCommand`: go/spawn, status, chart, sell/buy arrive with their stages), `/twadmin` (`DefaultAdminCommand`: seed info, island info, regen checks, rep set, force-scan…). +- Flags: security-band flag presets on NPC islands; new protection/world flags as systems land (e.g. `TW_MARKET`, `TW_CUSTOMS_EXEMPT`) — created per stage, not speculatively. +- Custom events for the ecosystem: `IslandChartedEvent`, `WarpEvent`/`WarpFailedEvent`, `CustomsScanEvent`, `SmugglingCaughtEvent`, `ReputationChangeEvent`, `BountyPaidEvent`, `TradeEvent`. +- Placeholders: reputation band/score, bounty, charted count, current island name/band, hold value. +- Locales: `locales/en-US.yml` reference-style; all player-facing text localized. +- Level addon: not meaningful for NPC islands; player islands (Stage 7) work unchanged. + +## 12. Milestones (= dev-plan stages; each ends green-build, playable, TESTING.md updated) + +- **Stage 0 — Scaffold:** GameModeAddon; overworld + interstice worlds (ocean generator, no acid); `/tw`/`/twadmin`; config skeleton; no End. *Accept:* loads alongside other gamemodes; `/tw` teleports to ocean spawn; interstice exists, players can't reach it. +- **Stage 1 — Seeded galaxy:** GalaxyEngine (positions/types/bands/biomes/names, pure + tested); generator radial mask; lazy island registration; name announce. *Accept:* same seed → identical galaxy across runs (unit-proved); rowing out reveals biomed islands, no pop-in; islands persist in DB. +- **Stage 2 — Island content:** deterministic dock shelf; blueprint sets per type/biome; jigsaw decoration (Boxed pattern); villagers + resident golems. +- **Stage 3 — Travel:** border proximity prompt + warp dialog; route graph; fuel table + hold consumption; warp execution with re-seat; charting. +- **Stage 4 — Economy & cargo:** BlueBook + modifiers + persisted drift; dock trade GUI (hold-only); bundle/chest-boat/expander progression; Vault. +- **Stage 5 — Interstice:** failure roll, mid-route placement, Ghasts, free re-engage, cleanup. +- **Stage 6 — Crime (3 sessions):** 6a reputation core; 6b customs & contraband; 6c police & bounties (+ nameplate, PlaceholderAPI). +- **Stage 7 — Player islands:** purchase + placement in reserved cells; standard island life. +- **Stage 8 — Post-MVP backlog:** stalls, chart data sales, intel, insurance, contracts, galaxies, Elder Guardian tier. + +## 13. Environment & Verify-First + +- **Toolchain (verified via Gusher — do not "upgrade" blindly):** Paper API `26.2.build.40-alpha` (Java 25 bytecode → build with JDK 25, compile release 21); BentoBox `3.18.1` provided (server runs 3.21.1-SNAPSHOT); MockBukkit has **no 26.2 release** — use locally patched `org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:4.113.4-p262` (GushBlock's `scripts/build_patched_mockbukkit.py`) + the two Adventure shims + surefire `--add-opens` set. `org.bukkit.Sound` is an **interface** (no `valueOf`); config-supplied sounds resolve via `RegistryAccess`. +- **Verify at Stage 3:** Paper 26.2 dialog API surface (design docs reference the 1.21.6 API; confirm current package/builders). +- **Verify at Stage 0/1:** BentoBox unowned-island registration path (`IslandsManager.createIsland` without owner) and per-world portal suppression for the interstice. +- Test server: `/Users/ben/Minecraft/26.2` (jar → `plugins/BentoBox/addons/`). Manual checklists live in `TESTING.md`. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md new file mode 100644 index 0000000..c97f57f --- /dev/null +++ b/docs/PROGRESS.md @@ -0,0 +1,56 @@ +# TradeWinds — Progress Log + +What is done, and pitfalls hit on the way. Newest stage first. Read +`TRADEWINDS_SPEC.md` for requirements; this file records reality. + +## Stage 0 — Addon scaffold (2026-07-29) — CODE COMPLETE, awaiting in-game test + +**Done:** +- Maven project (`pom.xml` cloned from GushBlock's verified 26.2 toolchain: + Paper API `26.2.build.40-alpha`, BentoBox 3.18.1, JDK 25 / release 21, + patched MockBukkit `4.113.4-p262`, surefire `--add-opens` set, JaCoCo). +- `TradeWinds extends GameModeAddon` (AcidIsland lifecycle pattern): + overworld `tradewinds_world` + interstice `tradewinds_world_nether` + (NETHER environment), **no End world ever** (spec principle 7). + `/tw` + `/twadmin` via Default{Player,Admin}Command; both default player + actions are `spawn` since TradeWinds players own no island at first. +- `ChunkGeneratorWorld`: ocean everywhere — bedrock, solid base, Perlin ocean + floor (8 octaves, scale 1/30, ±25 amplitude from `sea-floor` 25), water to + `sea-height` 70. Interstice identical shape with netherrack/basalt/soul-sand + palette. Vanilla noise/surface OFF so no vanilla continents can appear. + `terrainScale(worldInfo, x, z)` hook is where Stage 1's radial island mask + multiplies in. +- `TradeWindsBiomeProvider`: sea/air biome split (Poseidon pattern), interstice + biome for NETHER. Defaults OCEAN / OCEAN / NETHER_WASTES. +- `Settings` adapted from Gusher's (full WorldSettings), plus Stage 0 skeleton + knobs: `galaxy.*` (seed, min-separation, starter cluster), `travel.warp.*` + (fuel-per-block, failure-chance), `illegal-trade.enabled` master gate. +- Tests: 22, all green — Settings defaults, addon lifecycle incl. world + creation via ServerMock, generator shape/determinism/palette/terrainScale + hook, biome provider. + +**Pitfalls:** +- `org.bukkit.block.Biome` static init died in tests: 26.2 added + `minecraft:sulfur_caves` and the patched MockBukkit's `keyed/worldgen/biome.json` + lacked it. Fixed by adding `worldgen/biome` to the KEYED diff map in + `scripts/build_patched_mockbukkit.py` (note the registry file is under + `keyed/worldgen/`, not `keyed/`). Script re-run, artifact reinstalled, and the + updated script synced back to `~/git/GushBlock/scripts/` so a rerun there + cannot clobber the shared `~/.m2` artifact without the biome entries. +- Use `getServer().getWorld(...)` (not `Bukkit.getWorld`) in `createWorlds()` — + ServerMock backs it in tests, and `WorldCreator.createWorld()` resolves + through the mocked server, so world creation is fully unit-testable. + +**Interstice inaccessibility (Stage 0 accept):** `world.nether.create-and-link-portals` +is false so BentoBox never links portals into the interstice; there is no +command that goes there. Verify on the live server per `TESTING.md` — if some +other plugin (Multiverse) offers a route in, revisit with a listener at Stage 5. + +## Pre-Stage 0 (2026-07-29) + +- `TRADEWINDS_SPEC.md` written (consolidates overview/design-decisions/dev-plan; + spec wins on conflict). `CLAUDE.md` per GushBlock pattern. +- Reference intel gathered: AcidIsland (GameModeAddon anatomy, boat handling), + Poseidon (ocean noise), GushBlock (26.2 toolchain + MockBukkit patching), + BlueBook (`PriceEngine.getPrice(ItemStack, worldName)`), Border (visual + border, `BorderShower` API, `barrier-offset`/passable behavior). diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..dbd3c33 --- /dev/null +++ b/pom.xml @@ -0,0 +1,298 @@ + + + 4.0.0 + + world.bentobox + tradewinds + ${revision} + + TradeWinds + TradeWinds is a sea trading, smuggling, and piracy game mode add-on for BentoBox. + https://github.com/BentoBoxWorld/TradeWinds + 2026 + + + + tastybento + tastybento@bentobox.world + -8 + + Lead Developer + + + + + + scm:git:https://github.com/BentoBoxWorld/TradeWinds.git + scm:git:git@github.com:BentoBoxWorld/TradeWinds.git + https://github.com/BentoBoxWorld/TradeWinds + + + + + + bentoboxworld + https://repo.codemc.org/repository/bentoboxworld/ + + + + + UTF-8 + UTF-8 + 21 + + 5.10.2 + 5.11.0 + 4.113.4-p262 + + 26.2.build.40-alpha + 3.18.1 + + ${build.version}-SNAPSHOT + + -LOCAL + + 0.1.0 + + BentoBoxWorld_TradeWinds + bentobox-world + https://sonarcloud.io + ${project.build.directory}/site/jacoco/jacoco.xml + + + + + ci + + + env.BUILD_NUMBER + + + + -b${env.BUILD_NUMBER} + + + + master + + + env.GIT_BRANCH + origin/master + + + + ${build.version} + + + + + + + + jitpack.io + https://jitpack.io + true + true + + + bentoboxworld + https://repo.codemc.org/repository/bentoboxworld/ + true + true + + + papermc + https://repo.papermc.io/repository/maven-public/ + true + true + + + codemc-repo + https://repo.codemc.org/repository/maven-public/ + true + false + + + + + + org.mockbukkit.mockbukkit + mockbukkit-v26.1.2 + ${mock-bukkit.version} + test + + + io.papermc.paper + paper-api + ${paper.version} + provided + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + world.bentobox + bentobox + ${bentobox.version} + provided + + + + + ${project.name}-${revision}${build.number} + clean package + + + + src/main/resources + true + + locales/** + blueprints/** + + + + src/main/resources/locales + ./locales + false + + *.yml + + + + src/main/resources/blueprints + ./blueprints + false + + *.blu + *.blueprint + *.json + + + + + + org.apache.maven.plugins + maven-clean-plugin + 3.1.0 + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + blu + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + **/*Test.java + **/*Test?.java + **/*Test??.java + + @{argLine} + --add-opens java.base/java.lang=ALL-UNNAMED + --add-opens java.base/java.math=ALL-UNNAMED + --add-opens java.base/java.io=ALL-UNNAMED + --add-opens java.base/java.util=ALL-UNNAMED + --add-opens java.base/java.util.stream=ALL-UNNAMED + --add-opens java.base/java.text=ALL-UNNAMED + --add-opens java.base/java.util.regex=ALL-UNNAMED + --add-opens java.base/java.nio.channels.spi=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.net=ALL-UNNAMED + --add-opens java.base/java.util.concurrent=ALL-UNNAMED + --add-opens java.base/sun.nio.fs=ALL-UNNAMED + --add-opens java.base/sun.nio.cs=ALL-UNNAMED + --add-opens java.base/java.nio.file=ALL-UNNAMED + --add-opens java.base/java.nio.charset=ALL-UNNAMED + --add-opens java.base/java.lang.reflect=ALL-UNNAMED + --add-opens java.logging/java.util.logging=ALL-UNNAMED + --add-opens java.base/java.lang.ref=ALL-UNNAMED + --add-opens java.base/java.util.jar=ALL-UNNAMED + --add-opens java.base/java.util.zip=ALL-UNNAMED + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.1 + + + org.jacoco + jacoco-maven-plugin + 0.8.15 + + true + + **/*Names* + org/bukkit/Material* + + + + + pre-unit-test + + prepare-agent + + + + post-unit-test + + report + + + + + + + + diff --git a/scripts/build_patched_mockbukkit.py b/scripts/build_patched_mockbukkit.py new file mode 100755 index 0000000..944b05a --- /dev/null +++ b/scripts/build_patched_mockbukkit.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Build a MockBukkit jar patched for the Paper 26.2 API and install it to +~/.m2 as version 4.113.4-p262. + +MockBukkit has no 26.2 release. Compiling tests against Paper API 26.2 breaks +MockBukkit 4.113.4 (built for 26.1.2) in three data-driven ways, all fixed by +adding/patching JSON resources inside the jar (TagsMock walks its OWN jar +filesystem, so classpath shadowing cannot fix the tag files - hence a patched +jar rather than test-resource shadows): + +1. registries/registry_key_class_relation.json - 26.2 added the + minecraft:sulfur_cube_archetype and minecraft:zombie_nautilus_variant + registries; RegistryAccessMock aborts on unknown RegistryKey entries. +2. keyed/.json - Paper API classes with registry-backed constants + (GameEvent, Sound, ItemType, BlockType) initialise ALL constants; one + missing key kills the class. New entries are cloned from a template with + key-derived strings substituted. +3. tags//.json - org.bukkit.Tag fields for 26.2-new tags load + null, and Paper's MaterialTags/EntityTags static init NPEs on them. Missing + tags are created empty. + +A fourth incompatibility (MockBukkit's Adventure 4.x service providers vs +Paper 26.2's Adventure 5.2.0) is class-level and handled by a class shadow in +src/test/java/org/mockbukkit/mockbukkit/adventure/. + +Prerequisite: /tmp/paperapi contains the unzipped paper-api 26.2 jar: + cd /tmp && rm -rf paperapi && mkdir paperapi && cd paperapi \ + && unzip -q ~/.m2/repository/io/papermc/paper/paper-api/26.2.build.40-alpha/paper-api-26.2.build.40-alpha.jar + +Usage: python3 scripts/build_patched_mockbukkit.py +Delete all of this when MockBukkit ships a 26.2 build. +""" +import copy +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile + +SRC_VERSION = "4.113.4" +OUT_VERSION = SRC_VERSION + "-p262" +ARTIFACT = "mockbukkit-v26.1.2" +MBK_JAR = os.path.expanduser( + f"~/.m2/repository/org/mockbukkit/mockbukkit/{ARTIFACT}/{SRC_VERSION}/{ARTIFACT}-{SRC_VERSION}.jar") +PAPER_DIR = "/tmp/paperapi" + +# keyed registries to diff: registry -> (api class, no_dots, template key or None) +KEYED = { + "game_event": ("org/bukkit/GameEvent.class", False, None), + "sound_event": ("org/bukkit/Sound.class", False, None), + "item": ("org/bukkit/inventory/ItemType.class", True, "stone"), + "block": ("org/bukkit/block/BlockType.class", True, "stone"), + # 26.2 added biomes (e.g. minecraft:sulfur_caves); org.bukkit.block.Biome + # initialises every constant, so one missing key kills the class. + "worldgen/biome": ("org/bukkit/block/Biome.class", False, "ocean"), +} + +NEW_REGISTRIES = { + "minecraft:sulfur_cube_archetype": "org.bukkit.entity.SulfurCube$Archetype", + "minecraft:zombie_nautilus_variant": "org.bukkit.entity.ZombieNautilus$Variant", +} + +TAG_REGISTRY_DIRS = {"blocks", "items", "fluids", "entity_types", "game_events", "damage_types"} + + +def javap(class_path): + return subprocess.run(["javap", "-c", os.path.join(PAPER_DIR, class_path)], + capture_output=True, text=True).stdout + + +def api_keys(class_path, no_dots): + pattern = r"[a-z0-9_/]+" if no_dots else r"[a-z0-9_./]+" + keys = set() + for m in re.finditer(r"// String (\S+)$", javap(class_path), re.M): + if re.fullmatch(pattern, m.group(1)): + keys.add(m.group(1)) + return keys + + +def substitute(obj, old_bare, new_bare): + if isinstance(obj, str): + return re.sub(r"\b" + re.escape(old_bare) + r"\b", new_bare, obj) + if isinstance(obj, list): + return [substitute(v, old_bare, new_bare) for v in obj] + if isinstance(obj, dict): + return {k: substitute(v, old_bare, new_bare) for k, v in obj.items()} + return obj + + +def patched_keyed(zf, registry, class_path, no_dots, template_key): + data = json.loads(zf.read(f"keyed/{registry}.json")) + values = data["values"] + existing = {v["key"] for v in values} + template = next((v for v in values if v["key"] == "minecraft:" + template_key), values[0]) \ + if template_key else values[0] + template_bare = template["key"].split(":", 1)[1] + missing = sorted("minecraft:" + k for k in api_keys(class_path, no_dots) + if "minecraft:" + k not in existing) + for key in missing: + bare = key.split(":", 1)[1] + entry = substitute(copy.deepcopy(template), template_bare, bare) + entry["key"] = key + values.append(entry) + print(f" keyed/{registry}.json: +{len(missing)}") + return json.dumps(data, indent=1) + + +def tag_pairs(): + """(registry_dir, tag_name) pairs referenced by org.bukkit.Tag (26.2).""" + pairs = [] + prev = None + for line in javap("org/bukkit/Tag.class").splitlines(): + m = re.search(r"// String ([a-z0-9_/.]+)$", line) + if not m: + continue + s = m.group(1) + if s in TAG_REGISTRY_DIRS: + prev = s + elif prev is not None: + pairs.append((prev, s)) + prev = None + return sorted(set(pairs)) + + +def main(): + if not os.path.isdir(PAPER_DIR): + sys.exit(f"{PAPER_DIR} missing - unzip the paper-api jar there first (see docstring)") + + out_path = tempfile.mktemp(suffix=".jar") + with zipfile.ZipFile(MBK_JAR) as zin: + names = set(zin.namelist()) + replacements = {} + # 1. registry key class relation + rel = json.loads(zin.read("registries/registry_key_class_relation.json")) + rel.update(NEW_REGISTRIES) + replacements["registries/registry_key_class_relation.json"] = json.dumps(rel, indent=1, sort_keys=True) + print(" registries/registry_key_class_relation.json: +%d" % len(NEW_REGISTRIES)) + # 2. keyed registries + for registry, (class_path, no_dots, template) in KEYED.items(): + replacements[f"keyed/{registry}.json"] = patched_keyed(zin, registry, class_path, no_dots, template) + # 3. missing tags + additions = {} + for reg_dir, tag in tag_pairs(): + if "/" in tag: + # Nested tag dirs (items/sulfur_cube_archetype/*) break + # TagsMock's directory walk and silently abort ALL tag + # loading, nulling every org.bukkit.Tag field. Skip them; + # the corresponding Tag fields stay null, which nothing in + # MaterialTags/EntityTags reads. + print(f" tags: skipping nested {reg_dir}/{tag}") + continue + path = f"tags/{reg_dir}/{tag}.json" + if path not in names: + additions[path] = json.dumps({"replace": False, "values": []}, indent=1) + print(f" tags: +{len(additions)} empty tag files") + # write output jar + with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + content = replacements.get(item.filename) + if content is not None: + zout.writestr(item.filename, content) + else: + zout.writestr(item, zin.read(item.filename)) + for path, content in additions.items(): + zout.writestr(path, content) + + # Install with the ORIGINAL pom (version-bumped) so transitive deps + # (adventure, byte-buddy, etc.) still resolve - a bare install-file pom + # would drop them and break Mockito on Java 25. + src_pom = MBK_JAR[:-4] + ".pom" + pom_text = open(src_pom).read().replace( + f"{SRC_VERSION}", f"{OUT_VERSION}", 1) + pom_path = tempfile.mktemp(suffix=".pom") + open(pom_path, "w").write(pom_text) + subprocess.run([ + "mvn", "-q", "org.apache.maven.plugins:maven-install-plugin:3.1.1:install-file", + f"-Dfile={out_path}", + f"-DpomFile={pom_path}", + ], check=True) + os.unlink(out_path) + os.unlink(pom_path) + print(f"installed org.mockbukkit.mockbukkit:{ARTIFACT}:{OUT_VERSION}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci_prep.sh b/scripts/ci_prep.sh new file mode 100755 index 0000000..df784e6 --- /dev/null +++ b/scripts/ci_prep.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# CI prerequisite: rebuild the patched MockBukkit the tests depend on. +# +# MockBukkit has no Paper 26.2 release, so the tests use a locally patched jar +# (mockbukkit-v26.1.2:4.113.4-p262) that is published nowhere - which is why a +# clean CI checkout cannot resolve it. This script recreates it on the build +# agent: fetch the upstream base jar from PaperMC, unzip paper-api, then run +# scripts/build_patched_mockbukkit.py (which only rewrites JSON resources inside +# the base jar - no source build). Idempotent; safe to re-run. +# +# Run from the repo root, BEFORE `mvn`: +# bash scripts/ci_prep.sh +# +# Requirements on the agent: mvn, a JDK (for javap), python3, unzip. +# Versions are read from pom.xml so they never drift. +# Delete this (and build_patched_mockbukkit.py) when MockBukkit ships 26.2. +set -euo pipefail + +cd "$(dirname "$0")/.." + +version_of() { sed -n "s%.*<$1>\\([^<]*\\).*%\\1%p" pom.xml | head -n1; } + +PAPER_VERSION="$(version_of paper.version)" +MBK_BASE="$(version_of mock-bukkit.version)"; MBK_BASE="${MBK_BASE%-p262}" +echo "ci_prep: paper-api=$PAPER_VERSION base mockbukkit=$MBK_BASE" + +REPOS="https://repo.papermc.io/repository/maven-public/,https://repo.codemc.org/repository/maven-public/,https://repo1.maven.org/maven2/" + +mvn -B -q dependency:get -Dtransitive=false -DremoteRepositories="$REPOS" \ + -Dartifact="io.papermc.paper:paper-api:$PAPER_VERSION" +mvn -B -q dependency:get -Dtransitive=false -DremoteRepositories="$REPOS" \ + -Dartifact="org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:$MBK_BASE" + +PAPER_JAR="$HOME/.m2/repository/io/papermc/paper/paper-api/$PAPER_VERSION/paper-api-$PAPER_VERSION.jar" +rm -rf /tmp/paperapi && mkdir -p /tmp/paperapi +( cd /tmp/paperapi && unzip -q "$PAPER_JAR" ) + +python3 scripts/build_patched_mockbukkit.py +echo "ci_prep: done" diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java new file mode 100644 index 0000000..1c91464 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -0,0 +1,2028 @@ +package world.bentobox.tradewinds; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Difficulty; +import org.bukkit.Material; +import org.bukkit.GameMode; +import org.bukkit.block.Biome; +import org.bukkit.entity.EntityType; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.configuration.ConfigComment; +import world.bentobox.bentobox.api.configuration.ConfigEntry; +import world.bentobox.bentobox.api.configuration.StoreAt; +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.flags.Flag; +import world.bentobox.bentobox.database.objects.adapters.Adapter; +import world.bentobox.bentobox.database.objects.adapters.FlagBooleanSerializer; + + +/** + * All the plugin settings are here + * + * @author Tastybento + */ +@StoreAt(filename="config.yml", path="addons/TradeWinds") // Explicitly call out what name this should have. +@ConfigComment("TradeWinds Configuration [version]") +public class Settings implements WorldSettings { + + /* Commands */ + @ConfigComment("Player command. What command users will run to access their island.") + @ConfigComment("To define alias, just separate commands with white space.") + @ConfigEntry(path = "tradewinds.command.island") //, since = "1.3.0") + private String playerCommandAliases = "tw tradewinds"; + + @ConfigComment("The admin command.") + @ConfigComment("To define alias, just separate commands with white space.") + @ConfigEntry(path = "tradewinds.command.admin") // , since = "1.3.0") + private String adminCommandAliases = "twadmin"; + + @ConfigComment("The default action for new player command call.") + @ConfigComment("Sub-command of main player command that will be run on first player command call.") + @ConfigComment("TradeWinds players do not start with an island, so the default is 'spawn'.") + @ConfigEntry(path = "tradewinds.command.new-player-action") + private String defaultNewPlayerAction = "spawn"; + + @ConfigComment("The default action for player command.") + @ConfigComment("Sub-command of main player command that will be run on each player command call.") + @ConfigComment("TradeWinds players do not start with an island, so the default is 'spawn'.") + @ConfigEntry(path = "tradewinds.command.default-action") + private String defaultPlayerAction = "spawn"; + + /* GALAXY */ + @ConfigComment("The galaxy seed. Every island position, type, security band, biome, name and") + @ConfigComment("route cost derives deterministically from this one number - share it and another") + @ConfigComment("server gets the same trading galaxy. 0 means: use the world seed.") + @ConfigEntry(path = "galaxy.seed", needsReset = true) + private long galaxySeed = 0; + + @ConfigComment("Minimum separation between trading island centers in blocks. Enforced by") + @ConfigComment("rejection sampling at placement; must be at least 2x distance-between-islands.") + @ConfigEntry(path = "galaxy.min-separation", needsReset = true) + private int galaxyMinSeparation = 2500; + + @ConfigComment("Radius in blocks around spawn inside which the starter-cluster density floor applies.") + @ConfigEntry(path = "galaxy.starter-cluster-radius", needsReset = true) + private int starterClusterRadius = 5000; + + @ConfigComment("Minimum number of trading islands guaranteed inside the starter cluster radius,") + @ConfigComment("regardless of seed luck. These are pre-charted for new players.") + @ConfigEntry(path = "galaxy.starter-cluster-min-islands", needsReset = true) + private int starterClusterMinIslands = 5; + + /* TRAVEL */ + @ConfigComment("Base warp fuel cost multiplier: fuel units per block of Euclidean route distance.") + @ConfigComment("Per-edge overrides come later via the route-graph config.") + @ConfigEntry(path = "travel.warp.fuel-per-block") + private double fuelPerBlock = 0.01; + + @ConfigComment("Chance (0.0-1.0) that a warp fails and drops the player into the interstice.") + @ConfigComment("Re-engaging the warp from the interstice to the original destination is always free.") + @ConfigEntry(path = "travel.warp.failure-chance") + private double warpFailureChance = 0.05; + + /* ILLEGAL TRADE */ + @ConfigComment("Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling.") + @ConfigComment("Set false for family-friendly servers - removes the entire crime layer cleanly.") + @ConfigEntry(path = "illegal-trade.enabled") + private boolean illegalTradeEnabled = true; + + /* DEBUG */ + @ConfigComment("Log detailed [TradeWinds DEBUG] lines around galaxy generation, travel and trade.") + @ConfigEntry(path = "debug") + private boolean debug = false; + + /* WORLD */ + @ConfigComment("Friendly name for this world. Used in admin commands. Must be a single word") + @ConfigEntry(path = "world.friendly-name") + private String friendlyName = "TradeWinds"; + + @ConfigComment("Name of the world - if it does not exist then it will be generated.") + @ConfigComment("It acts like a prefix for nether and end (e.g. oneblock_world, oneblock_world_nether, oneblock_world_end)") + @ConfigEntry(path = "world.world-name") + private String worldName = "tradewinds_world"; + + @ConfigComment("World difficulty setting - PEACEFUL, EASY, NORMAL, HARD") + @ConfigComment("Other plugins may override this setting") + @ConfigEntry(path = "world.difficulty") + private Difficulty difficulty = Difficulty.NORMAL; + + + + + + + + + + + + + + + + @ConfigComment("Spawn limits. These override the limits set in bukkit.yml") + @ConfigComment("If set to a negative number, the server defaults will be used") + @ConfigEntry(path = "world.spawn-limits.monsters") // , since = "1.11.2") + private int spawnLimitMonsters = -1; + @ConfigEntry(path = "world.spawn-limits.animals") // , since = "1.11.2") + private int spawnLimitAnimals = -1; + @ConfigEntry(path = "world.spawn-limits.water-animals") // , since = "1.11.2") + private int spawnLimitWaterAnimals = -1; + @ConfigEntry(path = "world.spawn-limits.ambient") // , since = "1.11.2") + private int spawnLimitAmbient = -1; + @ConfigComment("Setting to 0 will disable animal spawns, but this is not recommended. Minecraft default is 400.") + @ConfigComment("A negative value uses the server default") + @ConfigEntry(path = "world.spawn-limits.ticks-per-animal-spawns") // , since = "1.11.2") + private int ticksPerAnimalSpawns = -1; + @ConfigComment("Setting to 0 will disable monster spawns, but this is not recommended. Minecraft default is 400.") + @ConfigComment("A negative value uses the server default") + @ConfigEntry(path = "world.spawn-limits.ticks-per-monster-spawns") // , since = "1.11.2") + private int ticksPerMonsterSpawns = -1; + + @ConfigComment("Radius of island in blocks. (So distance between islands is twice this)") + @ConfigComment("It is the same for every dimension : Overworld, Nether and End.") + @ConfigComment("This value cannot be changed mid-game and the plugin will not start if it is different.") + @ConfigEntry(path = "world.distance-between-islands", needsReset = true) + private int islandDistance = 1000; + + @ConfigComment("Default protection range radius in blocks. Cannot be larger than distance.") + @ConfigComment("Admins can change protection sizes for players individually using /obadmin range set ") + @ConfigComment("or set this permission: aoneblock.island.range.") + @ConfigEntry(path = "world.protection-range") + private int islandProtectionRange = 400; + + @ConfigComment("Start islands at these coordinates. This is where new islands will start in the") + @ConfigComment("world. These must be a factor of your island distance, but the plugin will auto") + @ConfigComment("calculate the closest location on the grid. Islands develop around this location") + @ConfigComment("both positively and negatively in a square grid.") + @ConfigComment("If none of this makes sense, leave it at 0,0.") + @ConfigEntry(path = "world.start-x", needsReset = true) + private int islandStartX = 0; + + @ConfigEntry(path = "world.start-z", needsReset = true) + private int islandStartZ = 0; + + @ConfigEntry(path = "world.offset-x") + private int islandXOffset; + @ConfigEntry(path = "world.offset-z") + private int islandZOffset; + + @ConfigComment("Island height - Lowest is 5.") + @ConfigComment("It is the y coordinate of the bedrock block in the schem.") + @ConfigEntry(path = "world.island-height") + private int islandHeight = 70; + + @ConfigComment("The number of concurrent islands a player can have in the world") + @ConfigComment("A value of 0 will use the BentoBox config.yml default") + @ConfigEntry(path = "world.concurrent-islands") + private int concurrentIslands = 0; + + @ConfigComment("Disallow team members from having their own islands.") + @ConfigEntry(path = "world.disallow-team-member-islands") + private boolean disallowTeamMemberIslands = true; + + @ConfigComment("Use your own world generator for this world.") + @ConfigComment("In this case, the plugin will not generate anything.") + @ConfigComment("If used, you must specify the world name and generator in the bukkit.yml file.") + @ConfigComment("See https://bukkit.gamepedia.com/Bukkit.yml#.2AOPTIONAL.2A_worlds") + @ConfigEntry(path = "world.use-own-generator") + private boolean useOwnGenerator; + + @ConfigComment("Sea height (don't changes this mid-game unless you delete the world)") + @ConfigComment("Minimum is 0") + @ConfigComment("If sea height is less than about 10, then players will drop right through it") + @ConfigComment("if it exists.") + @ConfigEntry(path = "world.sea-height", needsReset = true) + private int seaHeight = 70; + + @ConfigComment("Sea floor level - the base y below which the ocean floor terrain noise starts.") + @ConfigEntry(path = "world.sea-floor", needsReset = true) + private int seaFloor = 25; + + @ConfigComment("Water block for the overworld ocean.") + @ConfigEntry(path = "world.water-block", needsReset = true) + private Material waterBlock = Material.WATER; + + @ConfigComment("Allow vanilla cave generation under the ocean floor.") + @ConfigEntry(path = "world.make-caves") + private boolean makeCaves = false; + + @ConfigComment("Allow vanilla decoration (kelp, seagrass, trees on island land).") + @ConfigEntry(path = "world.make-decorations") + private boolean makeDecorations = true; + + @ConfigComment("Allow vanilla structure generation (shipwrecks, ruins...).") + @ConfigEntry(path = "world.make-structures") + private boolean makeStructures = false; + + @ConfigComment("Maximum number of islands in the world. Set to -1 or 0 for unlimited.") + @ConfigComment("If the number of islands is greater than this number, it will stop players from creating islands.") + @ConfigEntry(path = "world.max-islands") + private int maxIslands = -1; + + @ConfigComment("The default game mode for this world. Players will be set to this mode when they create") + @ConfigComment("a new island for example. Options are SURVIVAL, CREATIVE, ADVENTURE, SPECTATOR") + @ConfigEntry(path = "world.default-game-mode") + private GameMode defaultGameMode = GameMode.SURVIVAL; + + @ConfigComment("The default biome for the ocean (at and below sea level)") + @ConfigEntry(path = "world.default-biome") + private Biome defaultBiome; + + @ConfigComment("The default biome above sea level in the overworld") + @ConfigEntry(path = "world.default-air-biome") + private Biome defaultAirBiome; + @ConfigComment("The default biome for the interstice (this may affect what mobs can spawn)") + @ConfigEntry(path = "world.default-nether-biome") + private Biome defaultNetherBiome; + @ConfigComment("The default biome for the end world (this may affect what mobs can spawn)") + @ConfigEntry(path = "world.default-end-biome") + private Biome defaultEndBiome; + + @ConfigComment("The maximum number of players a player can ban at any one time in this game mode.") + @ConfigComment("The permission acidisland.ban.maxlimit.X where X is a number can also be used per player") + @ConfigComment("-1 = unlimited") + @ConfigEntry(path = "world.ban-limit") + private int banLimit = -1; + + // Interstice (registered as this gamemode's nether world) + @ConfigComment("Generate the interstice - the hostile Nether-environment sea that failed warps") + @ConfigComment("drop players into. If false, warps never fail and the world is not created.") + @ConfigEntry(path = "world.nether.generate") + private boolean netherGenerate = true; + + @ConfigComment("Use the TradeWinds ocean generator for the interstice (true) instead of vanilla nether.") + @ConfigEntry(path = "world.nether.islands", needsReset = true) + private boolean netherIslands = true; + + @ConfigComment("Sea height in the interstice.") + @ConfigEntry(path = "world.nether.sea-height", needsReset = true) + private int intersticeSeaHeight = 70; + + @ConfigComment("Sea floor level in the interstice.") + @ConfigEntry(path = "world.nether.sea-floor", needsReset = true) + private int intersticeSeaFloor = 25; + + @ConfigComment("Water block for the interstice sea.") + @ConfigEntry(path = "world.nether.water-block", needsReset = true) + private Material intersticeWaterBlock = Material.WATER; + + @ConfigComment("Make the nether roof, if false, there is nothing up there") + @ConfigComment("Change to false if lag is a problem from the generation") + @ConfigComment("Only applies to islands Nether") + @ConfigEntry(path = "world.nether.roof") + private boolean netherRoof = false; + + @ConfigComment("Nether spawn protection radius - this is the distance around the nether spawn") + @ConfigComment("that will be protected from player interaction (breaking blocks, pouring lava etc.)") + @ConfigComment("Minimum is 0 (not recommended), maximum is 100. Default is 25.") + @ConfigComment("Only applies to vanilla nether") + @ConfigEntry(path = "world.nether.spawn-radius") + private int netherSpawnRadius = 32; + + @ConfigComment("This option indicates if nether portals should be linked via dimensions.") + @ConfigComment("Option will simulate vanilla portal mechanics that links portals together") + @ConfigComment("or creates a new portal, if there is not a portal in that dimension.") + @ConfigComment("This option requires `allow-nether=true` in server.properties.") + @ConfigEntry(path = "world.nether.create-and-link-portals") // , since = "1.16") + private boolean makeNetherPortals = false; + + // End + @ConfigComment("End Nether - if this is false, the end world will not be made and access to") + @ConfigComment("the end will not occur. Other plugins may still enable portal usage.") + @ConfigEntry(path = "world.end.generate") + private boolean endGenerate = false; + + @ConfigComment("Islands in The End. Change to false for standard vanilla end.") + @ConfigComment("Note that there is currently no magic block in the End") + @ConfigEntry(path = "world.end.islands", needsReset = true) + private boolean endIslands = false; + + @ConfigComment("This option indicates if obsidian platform in the end should be generated") + @ConfigComment("when player enters the end world.") + @ConfigComment("This option requires `allow-end=true` in bukkit.yml.") + @ConfigEntry(path = "world.end.create-obsidian-platform") // , since = "1.16") + private boolean makeEndPortals = false; + + @ConfigComment("Mob white list - these mobs will NOT be removed when logging in or doing /island") + @ConfigEntry(path = "world.remove-mobs-whitelist") + private Set removeMobsWhitelist = new HashSet<>(); + + @ConfigComment("World flags. These are boolean settings for various flags for this world") + @ConfigEntry(path = "world.flags") + private Map worldFlags = new HashMap<>(); + + @ConfigComment("These are the default protection settings for new islands.") + @ConfigComment("The value is the minimum island rank required allowed to do the action") + @ConfigComment("Ranks are the following:") + @ConfigComment(" VISITOR = 0") + @ConfigComment(" COOP = 200") + @ConfigComment(" TRUSTED = 400") + @ConfigComment(" MEMBER = 500") + @ConfigComment(" SUB-OWNER = 900") + @ConfigComment(" OWNER = 1000") + @ConfigEntry(path = "world.default-island-flags") + private Map defaultIslandFlagNames = new HashMap<>(); + + @ConfigComment("These are the default settings for new islands") + @ConfigEntry(path = "world.default-island-settings") + @Adapter(FlagBooleanSerializer.class) + private Map defaultIslandSettingNames = new HashMap<>(); + + @ConfigComment("These settings/flags are hidden from users") + @ConfigComment("Ops can toggle hiding in-game using SHIFT-LEFT-CLICK on flags in settings") + @ConfigEntry(path = "world.hidden-flags") // , since = "1.4.1") + private List hiddenFlags = new ArrayList<>(); + + @ConfigComment("Visitor banned commands - Visitors to islands cannot use these commands in this world") + @ConfigEntry(path = "world.visitor-banned-commands") + private List visitorBannedCommands = new ArrayList<>(); + + @ConfigComment("Falling banned commands - players cannot use these commands when falling") + @ConfigComment("if the PREVENT_TELEPORT_WHEN_FALLING world setting flag is active") + @ConfigEntry(path = "world.falling-banned-commands") // , since = "1.8.0") + private List fallingBannedCommands = new ArrayList<>(); + + // --------------------------------------------- + + /* ISLAND */ + + + + @ConfigComment("Default max team size") + @ConfigComment("Permission size cannot be less than the default below. ") + @ConfigEntry(path = "island.max-team-size") + private int maxTeamSize = 4; + + @ConfigComment("Default maximum number of coop rank members per island") + @ConfigComment("Players can have the aoneblock.coop.maxsize. permission to be bigger but") + @ConfigComment("permission size cannot be less than the default below. ") + @ConfigEntry(path = "island.max-coop-size") // , since = "1.13.0") + private int maxCoopSize = 4; + + @ConfigComment("Default maximum number of trusted rank members per island") + @ConfigComment("Players can have the aoneblock.trust.maxsize. permission to be bigger but") + @ConfigComment("permission size cannot be less than the default below. ") + @ConfigEntry(path = "island.max-trusted-size") // , since = "1.13.0") + private int maxTrustSize = 4; + + @ConfigComment("Default maximum number of homes a player can have. Min = 1") + @ConfigComment("Accessed via /is sethome or /is go ") + @ConfigEntry(path = "island.max-homes") + private int maxHomes = 5; + + // Reset + @ConfigComment("How many resets a player is allowed (manage with /obadmin reset add/remove/reset/set command)") + @ConfigComment("Value of -1 means unlimited, 0 means hardcore - no resets.") + @ConfigComment("Example, 2 resets means they get 2 resets or 3 islands lifetime") + @ConfigEntry(path = "island.reset.reset-limit") + private int resetLimit = -1; + + @ConfigComment("Kicked or leaving players lose resets") + @ConfigComment("Players who leave a team will lose an island reset chance") + @ConfigComment("If a player has zero resets left and leaves a team, they cannot make a new") + @ConfigComment("island by themselves and can only join a team.") + @ConfigComment("Leave this true to avoid players exploiting free islands") + @ConfigEntry(path = "island.reset.leavers-lose-reset") + private boolean leaversLoseReset = false; + + @ConfigComment("Allow kicked players to keep their inventory.") + @ConfigComment("Overrides the on-leave inventory reset for kicked players.") + @ConfigEntry(path = "island.reset.kicked-keep-inventory") + private boolean kickedKeepInventory = false; + + @ConfigComment("What the addon should reset when the player joins or creates an island") + @ConfigComment("Reset Money - if this is true, will reset the player's money to the starting money") + @ConfigComment("Recommendation is that this is set to true, but if you run multi-worlds") + @ConfigComment("make sure your economy handles multi-worlds too.") + @ConfigEntry(path = "island.reset.on-join.money") + private boolean onJoinResetMoney = false; + + @ConfigComment("Reset inventory - if true, the player's inventory will be cleared.") + @ConfigComment("Note: if you have MultiInv running or a similar inventory control plugin, that") + @ConfigComment("plugin may still reset the inventory when the world changes.") + @ConfigEntry(path = "island.reset.on-join.inventory") + private boolean onJoinResetInventory = true; + + @ConfigComment("Reset health - if true, the player's health will be reset.") + @ConfigEntry(path = "island.reset.on-join.health") // , since = "1.8.0") + private boolean onJoinResetHealth = true; + + @ConfigComment("Reset hunger - if true, the player's hunger will be reset.") + @ConfigEntry(path = "island.reset.on-join.hunger") // , since = "1.8.0") + private boolean onJoinResetHunger = true; + + @ConfigComment("Reset experience points - if true, the player's experience will be reset.") + @ConfigEntry(path = "island.reset.on-join.exp") // , since = "1.8.0") + private boolean onJoinResetXP = true; + + + @ConfigComment("Reset Ender Chest - if true, the player's Ender Chest will be cleared.") + @ConfigEntry(path = "island.reset.on-join.ender-chest") + private boolean onJoinResetEnderChest = false; + + @ConfigComment("What the plugin should reset when the player leaves or is kicked from an island") + @ConfigComment("Reset Money - if this is true, will reset the player's money to the starting money") + @ConfigComment("Recommendation is that this is set to true, but if you run multi-worlds") + @ConfigComment("make sure your economy handles multi-worlds too.") + @ConfigEntry(path = "island.reset.on-leave.money") + private boolean onLeaveResetMoney = false; + + @ConfigComment("Reset inventory - if true, the player's inventory will be cleared.") + @ConfigComment("Note: if you have MultiInv running or a similar inventory control plugin, that") + @ConfigComment("plugin may still reset the inventory when the world changes.") + @ConfigEntry(path = "island.reset.on-leave.inventory") + private boolean onLeaveResetInventory = false; + + @ConfigComment("Reset health - if true, the player's health will be reset.") + @ConfigEntry(path = "island.reset.on-leave.health") // , since = "1.8.0") + private boolean onLeaveResetHealth = false; + + @ConfigComment("Reset hunger - if true, the player's hunger will be reset.") + @ConfigEntry(path = "island.reset.on-leave.hunger") // , since = "1.8.0") + private boolean onLeaveResetHunger = false; + + @ConfigComment("Reset experience - if true, the player's experience will be reset.") + @ConfigEntry(path = "island.reset.on-leave.exp") // , since = "1.8.0") + private boolean onLeaveResetXP = false; + + @ConfigComment("Reset Ender Chest - if true, the player's Ender Chest will be cleared.") + @ConfigEntry(path = "island.reset.on-leave.ender-chest") + private boolean onLeaveResetEnderChest = false; + + @ConfigComment("Toggles the automatic island creation upon the player's first login on your server.") + @ConfigComment("If set to true,") + @ConfigComment(" * Upon connecting to your server for the first time, the player will be told that") + @ConfigComment(" an island will be created for him.") + @ConfigComment(" * Make sure you have a Blueprint Bundle called \"default\": this is the one that will") + @ConfigComment(" be used to create the island.") + @ConfigComment(" * An island will be created for the player without needing him to run the create command.") + @ConfigComment("If set to false, this will disable this feature entirely.") + @ConfigComment("Warning:") + @ConfigComment(" * If you are running multiple gamemodes on your server, and all of them have") + @ConfigComment(" this feature enabled, an island in all the gamemodes will be created simultaneously.") + @ConfigComment(" However, it is impossible to know on which island the player will be teleported to afterwards.") + @ConfigComment(" * Island creation can be resource-intensive, please consider the options below to help mitigate") + @ConfigComment(" the potential issues, especially if you expect a lot of players to connect to your server") + @ConfigComment(" in a limited period of time.") + @ConfigEntry(path = "island.create-island-on-first-login.enable") // , since = "1.9.0") + private boolean createIslandOnFirstLoginEnabled; + + @ConfigComment("Time in seconds after the player logged in, before his island gets created.") + @ConfigComment("If set to 0 or less, the island will be created directly upon the player's login.") + @ConfigComment("It is recommended to keep this value under a minute's time.") + @ConfigEntry(path = "island.create-island-on-first-login.delay") // , since = "1.9.0") + private int createIslandOnFirstLoginDelay = 5; + + @ConfigComment("Toggles whether the island creation should be aborted if the player logged off while the") + @ConfigComment("delay (see the option above) has not worn off yet.") + @ConfigComment("If set to true,") + @ConfigComment(" * If the player has logged off the server while the delay (see the option above) has not") + @ConfigComment(" worn off yet, this will cancel the island creation.") + @ConfigComment(" * If the player relogs afterward, since he will not be recognized as a new player, no island") + @ConfigComment(" would be created for him.") + @ConfigComment(" * If the island creation started before the player logged off, it will continue.") + @ConfigComment("If set to false, the player's island will be created even if he went offline in the meantime.") + @ConfigComment("Note this option has no effect if the delay (see the option above) is set to 0 or less.") + @ConfigEntry(path = "island.create-island-on-first-login.abort-on-logout") // , since = "1.9.0") + private boolean createIslandOnFirstLoginAbortOnLogout = true; + + @ConfigComment("Toggles whether the player should be teleported automatically to his island when it is created.") + @ConfigComment("If set to false, the player will be told his island is ready but will have to teleport to his island using the command.") + @ConfigEntry(path = "island.teleport-player-to-island-when-created") // , since = "1.10.0") + private boolean teleportPlayerToIslandUponIslandCreation = true; + + @ConfigComment("Create Nether or End islands if they are missing when a player goes through a portal.") + @ConfigComment("Nether and End islands are usually pasted when a player makes their island, but if they are") + @ConfigComment("missing for some reason, you can switch this on.") + @ConfigComment("Note that bedrock removal glitches can exploit this option.") + @ConfigEntry(path = "island.create-missing-nether-end-islands") // , since = "1.10.0") + private boolean pasteMissingIslands = false; + + // Commands + @ConfigComment("List of commands to run when a player joins an island or creates one.") + @ConfigComment("These commands are run by the console, unless otherwise stated using the [SUDO] prefix,") + @ConfigComment("in which case they are executed by the player.") + @ConfigComment("") + @ConfigComment("Available placeholders for the commands are the following:") + @ConfigComment(" * [name]: name of the player") + @ConfigComment("") + @ConfigComment("Here are some examples of valid commands to execute:") + @ConfigComment(" * \"[SUDO] bbox version\"") + @ConfigComment(" * \"obadmin deaths set [player] 0\"") + @ConfigEntry(path = "island.commands.on-join") // , since = "1.8.0") + private List onJoinCommands = new ArrayList<>(); + + @ConfigComment("List of commands to run when a player leaves an island, resets his island or gets kicked from it.") + @ConfigComment("These commands are run by the console, unless otherwise stated using the [SUDO] prefix,") + @ConfigComment("in which case they are executed by the player.") + @ConfigComment("") + @ConfigComment("Available placeholders for the commands are the following:") + @ConfigComment(" * [name]: name of the player") + @ConfigComment("") + @ConfigComment("Here are some examples of valid commands to execute:") + @ConfigComment(" * '[SUDO] bbox version'") + @ConfigComment(" * 'obadmin deaths set [player] 0'") + @ConfigComment("") + @ConfigComment("Note that player-executed commands might not work, as these commands can be run with said player being offline.") + @ConfigEntry(path = "island.commands.on-leave") // , since = "1.8.0") + private List onLeaveCommands = new ArrayList<>(); + + @ConfigComment("List of commands that should be executed when the player respawns after death if Flags.ISLAND_RESPAWN is true.") + @ConfigComment("These commands are run by the console, unless otherwise stated using the [SUDO] prefix,") + @ConfigComment("in which case they are executed by the player.") + @ConfigComment("") + @ConfigComment("Available placeholders for the commands are the following:") + @ConfigComment(" * [name]: name of the player") + @ConfigComment("") + @ConfigComment("Here are some examples of valid commands to execute:") + @ConfigComment(" * '[SUDO] bbox version'") + @ConfigComment(" * 'obadmin deaths set [player] 0'") + @ConfigComment("") + @ConfigComment("Note that player-executed commands might not work, as these commands can be run with said player being offline.") + @ConfigEntry(path = "island.commands.on-respawn") // , since = "1.14.0") + private List onRespawnCommands = new ArrayList<>(); + + // Sethome + @ConfigEntry(path = "island.sethome.nether.allow") + private boolean allowSetHomeInNether = true; + + @ConfigEntry(path = "island.sethome.nether.require-confirmation") + private boolean requireConfirmationToSetHomeInNether = true; + + @ConfigEntry(path = "island.sethome.the-end.allow") + private boolean allowSetHomeInTheEnd = true; + + @ConfigEntry(path = "island.sethome.the-end.require-confirmation") + private boolean requireConfirmationToSetHomeInTheEnd = true; + + // Deaths + @ConfigComment("Whether deaths are counted or not.") + @ConfigEntry(path = "island.deaths.counted") + private boolean deathsCounted = true; + + @ConfigComment("Maximum number of deaths to count. The death count can be used by add-ons.") + @ConfigEntry(path = "island.deaths.max") + private int deathsMax = 10; + + @ConfigComment("When a player joins a team, reset their death count") + @ConfigEntry(path = "island.deaths.team-join-reset") + private boolean teamJoinDeathReset = true; + + @ConfigComment("Reset player death count when they start a new island or reset an island") + @ConfigEntry(path = "island.deaths.reset-on-new-island") // , since = "1.6.0") + private boolean deathsResetOnNewIsland = true; + + // --------------------------------------------- + /* PROTECTION */ + + @ConfigComment("Geo restrict mobs.") + @ConfigComment("Mobs that exit the island space where they were spawned will be removed.") + @ConfigEntry(path = "protection.geo-limit-settings") + private List geoLimitSettings = new ArrayList<>(); + + @ConfigComment("AOneBlock blocked mobs.") + @ConfigComment("List of mobs that should not spawn in AOneBlock.") + @ConfigEntry(path = "protection.block-mobs") // , since = "1.2.0") + private List mobLimitSettings = new ArrayList<>(); + + + // Invincible visitor settings + @ConfigComment("Invincible visitors. List of damages that will not affect visitors.") + @ConfigComment("Make list blank if visitors should receive all damages") + @ConfigEntry(path = "protection.invincible-visitors") + private List ivSettings = new ArrayList<>(); + + //---------------------------------------------------------------------------------------/ + @ConfigComment("These settings should not be edited") + @ConfigEntry(path = "do-not-edit-these-settings.reset-epoch") + private long resetEpoch = 0; + + /** + * @return the friendlyName + */ + @Override + public String getFriendlyName() { + return friendlyName; + } + + /** + * @return the worldName + */ + @Override + public String getWorldName() { + return worldName; + } + + /** + * @return the difficulty + */ + @Override + public Difficulty getDifficulty() { + return difficulty; + } + + /** + * @return the islandDistance + */ + @Override + public int getIslandDistance() { + return islandDistance; + } + + /** + * @return the islandProtectionRange + */ + @Override + public int getIslandProtectionRange() { + return islandProtectionRange; + } + + /** + * @return the islandStartX + */ + @Override + public int getIslandStartX() { + return islandStartX; + } + + /** + * @return the islandStartZ + */ + @Override + public int getIslandStartZ() { + return islandStartZ; + } + + /** + * @return the islandXOffset + */ + @Override + public int getIslandXOffset() { + return islandXOffset; + } + + /** + * @return the islandZOffset + */ + @Override + public int getIslandZOffset() { + return islandZOffset; + } + + /** + * @return the islandHeight + */ + @Override + public int getIslandHeight() { + return islandHeight; + } + + /** + * @return the useOwnGenerator + */ + @Override + public boolean isUseOwnGenerator() { + return useOwnGenerator; + } + + /** + * @return the seaHeight + */ + @Override + public int getSeaHeight() { + return seaHeight; + } + + /** + * @return the maxIslands + */ + @Override + public int getMaxIslands() { + return maxIslands; + } + + /** + * @return the defaultGameMode + */ + @Override + public GameMode getDefaultGameMode() { + return defaultGameMode; + } + + /** + * @return the netherGenerate + */ + @Override + public boolean isNetherGenerate() { + return netherGenerate; + } + + /** + * @return the netherIslands + */ + @Override + public boolean isNetherIslands() { + return netherIslands; + } + + /** + * @return the netherRoof + */ + public boolean isNetherRoof() { + return netherRoof; + } + + /** + * @return the netherSpawnRadius + */ + @Override + public int getNetherSpawnRadius() { + return netherSpawnRadius; + } + + /** + * @return the endGenerate + */ + @Override + public boolean isEndGenerate() { + return endGenerate; + } + + /** + * @return the endIslands + */ + @Override + public boolean isEndIslands() { + return endIslands; + } + + /** + * @return the dragonSpawn + */ + @Override + public boolean isDragonSpawn() { + return false; + } + + /** + * @return the removeMobsWhitelist + */ + @Override + public Set getRemoveMobsWhitelist() { + return removeMobsWhitelist; + } + + /** + * @return the worldFlags + */ + @Override + public Map getWorldFlags() { + return worldFlags; + } + + + /** + * @return the defaultIslandFlags + * @since 1.21.0 + */ + @Override + public Map getDefaultIslandFlagNames() + { + return defaultIslandFlagNames; + } + + + /** + * @return the defaultIslandSettings + * @since 1.21.0 + */ + @Override + public Map getDefaultIslandSettingNames() + { + return defaultIslandSettingNames; + } + + + /** + * @return the defaultIslandFlags + * @deprecated Replaced with #getDefaultIslandFlagNames + * @since 1.21 + */ + @Override + @Deprecated(since = "1.21", forRemoval = true) + public Map getDefaultIslandFlags() { + return Collections.emptyMap(); + } + + /** + * @return the defaultIslandSettings + * @deprecated Replaced with #getDefaultIslandSettingNames + * @since 1.21 + */ + @Override + @Deprecated(since = "1.21", forRemoval = true) + public Map getDefaultIslandSettings() { + return Collections.emptyMap(); + } + + /** + * @return the hidden flags + */ + @Override + public List getHiddenFlags() { + return hiddenFlags; + } + + /** + * @return the visitorBannedCommands + */ + @Override + public List getVisitorBannedCommands() { + return visitorBannedCommands; + } + + /** + * @return the fallingBannedCommands + */ + @Override + public List getFallingBannedCommands() { + return fallingBannedCommands; + } + + /** + * @return the maxTeamSize + */ + @Override + public int getMaxTeamSize() { + return maxTeamSize; + } + + /** + * @return the maxHomes + */ + @Override + public int getMaxHomes() { + return maxHomes; + } + + /** + * @return the resetLimit + */ + @Override + public int getResetLimit() { + return resetLimit; + } + + /** + * @return the leaversLoseReset + */ + @Override + public boolean isLeaversLoseReset() { + return leaversLoseReset; + } + + /** + * @return the kickedKeepInventory + */ + @Override + public boolean isKickedKeepInventory() { + return kickedKeepInventory; + } + + + /** + * This method returns the createIslandOnFirstLoginEnabled boolean value. + * @return the createIslandOnFirstLoginEnabled value + * @since 1.9.0 + */ + @Override + public boolean isCreateIslandOnFirstLoginEnabled() + { + return createIslandOnFirstLoginEnabled; + } + + + /** + * This method returns the createIslandOnFirstLoginDelay int value. + * @return the createIslandOnFirstLoginDelay value + * @since 1.9.0 + */ + @Override + public int getCreateIslandOnFirstLoginDelay() + { + return createIslandOnFirstLoginDelay; + } + + + /** + * This method returns the createIslandOnFirstLoginAbortOnLogout boolean value. + * @return the createIslandOnFirstLoginAbortOnLogout value + * @since 1.9.0 + */ + @Override + public boolean isCreateIslandOnFirstLoginAbortOnLogout() + { + return createIslandOnFirstLoginAbortOnLogout; + } + + + /** + * @return the onJoinResetMoney + */ + @Override + public boolean isOnJoinResetMoney() { + return onJoinResetMoney; + } + + /** + * @return the onJoinResetInventory + */ + @Override + public boolean isOnJoinResetInventory() { + return onJoinResetInventory; + } + + /** + * @return the onJoinResetEnderChest + */ + @Override + public boolean isOnJoinResetEnderChest() { + return onJoinResetEnderChest; + } + + /** + * @return the onLeaveResetMoney + */ + @Override + public boolean isOnLeaveResetMoney() { + return onLeaveResetMoney; + } + + /** + * @return the onLeaveResetInventory + */ + @Override + public boolean isOnLeaveResetInventory() { + return onLeaveResetInventory; + } + + /** + * @return the onLeaveResetEnderChest + */ + @Override + public boolean isOnLeaveResetEnderChest() { + return onLeaveResetEnderChest; + } + + /** + * @return the isDeathsCounted + */ + @Override + public boolean isDeathsCounted() { + return deathsCounted; + } + + /** + * @return the allowSetHomeInNether + */ + @Override + public boolean isAllowSetHomeInNether() { + return allowSetHomeInNether; + } + + /** + * @return the allowSetHomeInTheEnd + */ + @Override + public boolean isAllowSetHomeInTheEnd() { + return allowSetHomeInTheEnd; + } + + /** + * @return the requireConfirmationToSetHomeInNether + */ + @Override + public boolean isRequireConfirmationToSetHomeInNether() { + return requireConfirmationToSetHomeInNether; + } + + /** + * @return the requireConfirmationToSetHomeInTheEnd + */ + @Override + public boolean isRequireConfirmationToSetHomeInTheEnd() { + return requireConfirmationToSetHomeInTheEnd; + } + + /** + * @return the deathsMax + */ + @Override + public int getDeathsMax() { + return deathsMax; + } + + /** + * @return the teamJoinDeathReset + */ + @Override + public boolean isTeamJoinDeathReset() { + return teamJoinDeathReset; + } + + /** + * @return the geoLimitSettings + */ + @Override + public List getGeoLimitSettings() { + return geoLimitSettings; + } + + /** + * @return the ivSettings + */ + @Override + public List getIvSettings() { + return ivSettings; + } + + /** + * @return the resetEpoch + */ + @Override + public long getResetEpoch() { + return resetEpoch; + } + + /** + * @param friendlyName the friendlyName to set + */ + public void setFriendlyName(String friendlyName) { + this.friendlyName = friendlyName; + } + + /** + * @param worldName the worldName to set + */ + public void setWorldName(String worldName) { + this.worldName = worldName; + } + + /** + * @param difficulty the difficulty to set + */ + @Override + public void setDifficulty(Difficulty difficulty) { + this.difficulty = difficulty; + } + + /** + * @param islandDistance the islandDistance to set + */ + public void setIslandDistance(int islandDistance) { + this.islandDistance = islandDistance; + } + + /** + * @param islandProtectionRange the islandProtectionRange to set + */ + public void setIslandProtectionRange(int islandProtectionRange) { + this.islandProtectionRange = islandProtectionRange; + } + + /** + * @param islandStartX the islandStartX to set + */ + public void setIslandStartX(int islandStartX) { + this.islandStartX = islandStartX; + } + + /** + * @param islandStartZ the islandStartZ to set + */ + public void setIslandStartZ(int islandStartZ) { + this.islandStartZ = islandStartZ; + } + + /** + * @param islandXOffset the islandXOffset to set + */ + public void setIslandXOffset(int islandXOffset) { + this.islandXOffset = islandXOffset; + } + + /** + * @param islandZOffset the islandZOffset to set + */ + public void setIslandZOffset(int islandZOffset) { + this.islandZOffset = islandZOffset; + } + + /** + * @param islandHeight the islandHeight to set + */ + public void setIslandHeight(int islandHeight) { + this.islandHeight = islandHeight; + } + + /** + * @param useOwnGenerator the useOwnGenerator to set + */ + public void setUseOwnGenerator(boolean useOwnGenerator) { + this.useOwnGenerator = useOwnGenerator; + } + + /** + * @param seaHeight the seaHeight to set + */ + public void setSeaHeight(int seaHeight) { + this.seaHeight = seaHeight; + } + + /** + * @param maxIslands the maxIslands to set + */ + public void setMaxIslands(int maxIslands) { + this.maxIslands = maxIslands; + } + + /** + * @param defaultGameMode the defaultGameMode to set + */ + public void setDefaultGameMode(GameMode defaultGameMode) { + this.defaultGameMode = defaultGameMode; + } + + /** + * @param netherGenerate the netherGenerate to set + */ + public void setNetherGenerate(boolean netherGenerate) { + this.netherGenerate = netherGenerate; + } + + /** + * @param netherIslands the netherIslands to set + */ + public void setNetherIslands(boolean netherIslands) { + this.netherIslands = netherIslands; + } + + /** + * @param netherRoof the netherRoof to set + */ + public void setNetherRoof(boolean netherRoof) { + this.netherRoof = netherRoof; + } + + /** + * @param netherSpawnRadius the netherSpawnRadius to set + */ + public void setNetherSpawnRadius(int netherSpawnRadius) { + this.netherSpawnRadius = netherSpawnRadius; + } + + /** + * @param endGenerate the endGenerate to set + */ + public void setEndGenerate(boolean endGenerate) { + this.endGenerate = endGenerate; + } + + /** + * @param endIslands the endIslands to set + */ + public void setEndIslands(boolean endIslands) { + this.endIslands = endIslands; + } + + /** + * @param removeMobsWhitelist the removeMobsWhitelist to set + */ + public void setRemoveMobsWhitelist(Set removeMobsWhitelist) { + this.removeMobsWhitelist = removeMobsWhitelist; + } + + /** + * @param worldFlags the worldFlags to set + */ + public void setWorldFlags(Map worldFlags) { + this.worldFlags = worldFlags; + } + + + /** + * Sets default island flag names. + * + * @param defaultIslandFlagNames the default island flag names + */ + public void setDefaultIslandFlagNames(Map defaultIslandFlagNames) + { + this.defaultIslandFlagNames = defaultIslandFlagNames; + } + + + /** + * Sets default island setting names. + * + * @param defaultIslandSettingNames the default island setting names + */ + public void setDefaultIslandSettingNames(Map defaultIslandSettingNames) + { + this.defaultIslandSettingNames = defaultIslandSettingNames; + } + + + /** + * @param hiddenFlags the hidden flags to set + */ + public void setHiddenFlags(List hiddenFlags) { + this.hiddenFlags = hiddenFlags; + } + + /** + * @param visitorBannedCommands the visitorBannedCommands to set + */ + public void setVisitorBannedCommands(List visitorBannedCommands) { + this.visitorBannedCommands = visitorBannedCommands; + } + + /** + * @param fallingBannedCommands the fallingBannedCommands to set + */ + public void setFallingBannedCommands(List fallingBannedCommands) { + this.fallingBannedCommands = fallingBannedCommands; + } + + /** + * @param maxTeamSize the maxTeamSize to set + */ + public void setMaxTeamSize(int maxTeamSize) { + this.maxTeamSize = maxTeamSize; + } + + /** + * @param maxHomes the maxHomes to set + */ + public void setMaxHomes(int maxHomes) { + this.maxHomes = maxHomes; + } + + /** + * @param resetLimit the resetLimit to set + */ + public void setResetLimit(int resetLimit) { + this.resetLimit = resetLimit; + } + + /** + * @param leaversLoseReset the leaversLoseReset to set + */ + public void setLeaversLoseReset(boolean leaversLoseReset) { + this.leaversLoseReset = leaversLoseReset; + } + + /** + * @param kickedKeepInventory the kickedKeepInventory to set + */ + public void setKickedKeepInventory(boolean kickedKeepInventory) { + this.kickedKeepInventory = kickedKeepInventory; + } + + /** + * @param onJoinResetMoney the onJoinResetMoney to set + */ + public void setOnJoinResetMoney(boolean onJoinResetMoney) { + this.onJoinResetMoney = onJoinResetMoney; + } + + /** + * @param onJoinResetInventory the onJoinResetInventory to set + */ + public void setOnJoinResetInventory(boolean onJoinResetInventory) { + this.onJoinResetInventory = onJoinResetInventory; + } + + /** + * @param onJoinResetEnderChest the onJoinResetEnderChest to set + */ + public void setOnJoinResetEnderChest(boolean onJoinResetEnderChest) { + this.onJoinResetEnderChest = onJoinResetEnderChest; + } + + /** + * @param onLeaveResetMoney the onLeaveResetMoney to set + */ + public void setOnLeaveResetMoney(boolean onLeaveResetMoney) { + this.onLeaveResetMoney = onLeaveResetMoney; + } + + /** + * @param onLeaveResetInventory the onLeaveResetInventory to set + */ + public void setOnLeaveResetInventory(boolean onLeaveResetInventory) { + this.onLeaveResetInventory = onLeaveResetInventory; + } + + /** + * @param onLeaveResetEnderChest the onLeaveResetEnderChest to set + */ + public void setOnLeaveResetEnderChest(boolean onLeaveResetEnderChest) { + this.onLeaveResetEnderChest = onLeaveResetEnderChest; + } + + /** + * @param createIslandOnFirstLoginEnabled the createIslandOnFirstLoginEnabled to set + */ + public void setCreateIslandOnFirstLoginEnabled(boolean createIslandOnFirstLoginEnabled) + { + this.createIslandOnFirstLoginEnabled = createIslandOnFirstLoginEnabled; + } + + /** + * @param createIslandOnFirstLoginDelay the createIslandOnFirstLoginDelay to set + */ + public void setCreateIslandOnFirstLoginDelay(int createIslandOnFirstLoginDelay) + { + this.createIslandOnFirstLoginDelay = createIslandOnFirstLoginDelay; + } + + /** + * @param createIslandOnFirstLoginAbortOnLogout the createIslandOnFirstLoginAbortOnLogout to set + */ + public void setCreateIslandOnFirstLoginAbortOnLogout(boolean createIslandOnFirstLoginAbortOnLogout) + { + this.createIslandOnFirstLoginAbortOnLogout = createIslandOnFirstLoginAbortOnLogout; + } + + /** + * @param deathsCounted the deathsCounted to set + */ + public void setDeathsCounted(boolean deathsCounted) { + this.deathsCounted = deathsCounted; + } + + /** + * @param deathsMax the deathsMax to set + */ + public void setDeathsMax(int deathsMax) { + this.deathsMax = deathsMax; + } + + /** + * @param teamJoinDeathReset the teamJoinDeathReset to set + */ + public void setTeamJoinDeathReset(boolean teamJoinDeathReset) { + this.teamJoinDeathReset = teamJoinDeathReset; + } + + /** + * @param geoLimitSettings the geoLimitSettings to set + */ + public void setGeoLimitSettings(List geoLimitSettings) { + this.geoLimitSettings = geoLimitSettings; + } + + /** + * @param ivSettings the ivSettings to set + */ + public void setIvSettings(List ivSettings) { + this.ivSettings = ivSettings; + } + + /** + * @param allowSetHomeInNether the allowSetHomeInNether to set + */ + public void setAllowSetHomeInNether(boolean allowSetHomeInNether) { + this.allowSetHomeInNether = allowSetHomeInNether; + } + + /** + * @param allowSetHomeInTheEnd the allowSetHomeInTheEnd to set + */ + public void setAllowSetHomeInTheEnd(boolean allowSetHomeInTheEnd) { + this.allowSetHomeInTheEnd = allowSetHomeInTheEnd; + } + + /** + * @param requireConfirmationToSetHomeInNether the requireConfirmationToSetHomeInNether to set + */ + public void setRequireConfirmationToSetHomeInNether(boolean requireConfirmationToSetHomeInNether) { + this.requireConfirmationToSetHomeInNether = requireConfirmationToSetHomeInNether; + } + + /** + * @param requireConfirmationToSetHomeInTheEnd the requireConfirmationToSetHomeInTheEnd to set + */ + public void setRequireConfirmationToSetHomeInTheEnd(boolean requireConfirmationToSetHomeInTheEnd) { + this.requireConfirmationToSetHomeInTheEnd = requireConfirmationToSetHomeInTheEnd; + } + + /** + * @param resetEpoch the resetEpoch to set + */ + @Override + public void setResetEpoch(long resetEpoch) { + this.resetEpoch = resetEpoch; + } + + @Override + public String getPermissionPrefix() { + return "tradewinds"; + } + + @Override + public boolean isWaterUnsafe() { + return false; + } + + /** + * @return default biome + */ + public Biome getDefaultBiome() { + return defaultBiome == null ? Biome.OCEAN : defaultBiome; + } + + /** + * @param defaultBiome the defaultBiome to set + */ + public void setDefaultBiome(Biome defaultBiome) { + this.defaultBiome = defaultBiome; + } + + /** + * @return the banLimit + */ + @Override + public int getBanLimit() { + return banLimit; + } + + /** + * @param banLimit the banLimit to set + */ + public void setBanLimit(int banLimit) { + this.banLimit = banLimit; + } + + /** + * @return the playerCommandAliases + */ + @Override + public String getPlayerCommandAliases() { + return playerCommandAliases; + } + + /** + * @param playerCommandAliases the playerCommandAliases to set + */ + public void setPlayerCommandAliases(String playerCommandAliases) { + this.playerCommandAliases = playerCommandAliases; + } + + /** + * @return the adminCommandAliases + */ + @Override + public String getAdminCommandAliases() { + return adminCommandAliases; + } + + /** + * @param adminCommandAliases the adminCommandAliases to set + */ + public void setAdminCommandAliases(String adminCommandAliases) { + this.adminCommandAliases = adminCommandAliases; + } + + /** + * @return the deathsResetOnNew + */ + @Override + public boolean isDeathsResetOnNewIsland() { + return deathsResetOnNewIsland; + } + + /** + * @param deathsResetOnNew the deathsResetOnNew to set + */ + public void setDeathsResetOnNewIsland(boolean deathsResetOnNew) { + this.deathsResetOnNewIsland = deathsResetOnNew; + } + + /** + * @return the onJoinCommands + */ + @Override + public List getOnJoinCommands() { + return onJoinCommands; + } + + /** + * @param onJoinCommands the onJoinCommands to set + */ + public void setOnJoinCommands(List onJoinCommands) { + this.onJoinCommands = onJoinCommands; + } + + /** + * @return the onLeaveCommands + */ + @Override + public List getOnLeaveCommands() { + return onLeaveCommands; + } + + /** + * @param onLeaveCommands the onLeaveCommands to set + */ + public void setOnLeaveCommands(List onLeaveCommands) { + this.onLeaveCommands = onLeaveCommands; + } + + /** + * @return the onRespawnCommands + */ + @Override + public List getOnRespawnCommands() { + return onRespawnCommands; + } + + /** + * Sets on respawn commands. + * + * @param onRespawnCommands the on respawn commands + */ + public void setOnRespawnCommands(List onRespawnCommands) { + this.onRespawnCommands = onRespawnCommands; + } + + /** + * @return the onJoinResetHealth + */ + @Override + public boolean isOnJoinResetHealth() { + return onJoinResetHealth; + } + + /** + * @param onJoinResetHealth the onJoinResetHealth to set + */ + public void setOnJoinResetHealth(boolean onJoinResetHealth) { + this.onJoinResetHealth = onJoinResetHealth; + } + + /** + * @return the onJoinResetHunger + */ + @Override + public boolean isOnJoinResetHunger() { + return onJoinResetHunger; + } + + /** + * @param onJoinResetHunger the onJoinResetHunger to set + */ + public void setOnJoinResetHunger(boolean onJoinResetHunger) { + this.onJoinResetHunger = onJoinResetHunger; + } + + /** + * @return the onJoinResetXP + */ + @Override + public boolean isOnJoinResetXP() { + return onJoinResetXP; + } + + /** + * @param onJoinResetXP the onJoinResetXP to set + */ + public void setOnJoinResetXP(boolean onJoinResetXP) { + this.onJoinResetXP = onJoinResetXP; + } + + /** + * @return the onLeaveResetHealth + */ + @Override + public boolean isOnLeaveResetHealth() { + return onLeaveResetHealth; + } + + /** + * @param onLeaveResetHealth the onLeaveResetHealth to set + */ + public void setOnLeaveResetHealth(boolean onLeaveResetHealth) { + this.onLeaveResetHealth = onLeaveResetHealth; + } + + /** + * @return the onLeaveResetHunger + */ + @Override + public boolean isOnLeaveResetHunger() { + return onLeaveResetHunger; + } + + /** + * @param onLeaveResetHunger the onLeaveResetHunger to set + */ + public void setOnLeaveResetHunger(boolean onLeaveResetHunger) { + this.onLeaveResetHunger = onLeaveResetHunger; + } + + /** + * @return the onLeaveResetXP + */ + @Override + public boolean isOnLeaveResetXP() { + return onLeaveResetXP; + } + + /** + * @param onLeaveResetXP the onLeaveResetXP to set + */ + public void setOnLeaveResetXP(boolean onLeaveResetXP) { + this.onLeaveResetXP = onLeaveResetXP; + } + + /** + * @return the pasteMissingIslands + */ + @Override + public boolean isPasteMissingIslands() { + return pasteMissingIslands; + } + + /** + * @param pasteMissingIslands the pasteMissingIslands to set + */ + public void setPasteMissingIslands(boolean pasteMissingIslands) { + this.pasteMissingIslands = pasteMissingIslands; + } + + /** + * Toggles whether the player should be teleported automatically to his island when it is created. + * @return {@code true} if the player should be teleported automatically to his island when it is created, + * {@code false} otherwise. + * @since 1.10.0 + */ + @Override + public boolean isTeleportPlayerToIslandUponIslandCreation() { + return teleportPlayerToIslandUponIslandCreation; + } + + /** + * @param teleportPlayerToIslandUponIslandCreation the teleportPlayerToIslandUponIslandCreation to set + * @since 1.10.0 + */ + public void setTeleportPlayerToIslandUponIslandCreation(boolean teleportPlayerToIslandUponIslandCreation) { + this.teleportPlayerToIslandUponIslandCreation = teleportPlayerToIslandUponIslandCreation; + } + + /** + * @return the spawnLimitMonsters + */ + public int getSpawnLimitMonsters() { + return spawnLimitMonsters; + } + + /** + * @param spawnLimitMonsters the spawnLimitMonsters to set + */ + public void setSpawnLimitMonsters(int spawnLimitMonsters) { + this.spawnLimitMonsters = spawnLimitMonsters; + } + + /** + * @return the spawnLimitAnimals + */ + public int getSpawnLimitAnimals() { + return spawnLimitAnimals; + } + + /** + * @param spawnLimitAnimals the spawnLimitAnimals to set + */ + public void setSpawnLimitAnimals(int spawnLimitAnimals) { + this.spawnLimitAnimals = spawnLimitAnimals; + } + + /** + * @return the spawnLimitWaterAnimals + */ + public int getSpawnLimitWaterAnimals() { + return spawnLimitWaterAnimals; + } + + /** + * @param spawnLimitWaterAnimals the spawnLimitWaterAnimals to set + */ + public void setSpawnLimitWaterAnimals(int spawnLimitWaterAnimals) { + this.spawnLimitWaterAnimals = spawnLimitWaterAnimals; + } + + /** + * @return the spawnLimitAmbient + */ + public int getSpawnLimitAmbient() { + return spawnLimitAmbient; + } + + /** + * @param spawnLimitAmbient the spawnLimitAmbient to set + */ + public void setSpawnLimitAmbient(int spawnLimitAmbient) { + this.spawnLimitAmbient = spawnLimitAmbient; + } + + /** + * @return the ticksPerAnimalSpawns + */ + public int getTicksPerAnimalSpawns() { + return ticksPerAnimalSpawns; + } + + /** + * @param ticksPerAnimalSpawns the ticksPerAnimalSpawns to set + */ + public void setTicksPerAnimalSpawns(int ticksPerAnimalSpawns) { + this.ticksPerAnimalSpawns = ticksPerAnimalSpawns; + } + + /** + * @return the ticksPerMonsterSpawns + */ + public int getTicksPerMonsterSpawns() { + return ticksPerMonsterSpawns; + } + + /** + * @param ticksPerMonsterSpawns the ticksPerMonsterSpawns to set + */ + public void setTicksPerMonsterSpawns(int ticksPerMonsterSpawns) { + this.ticksPerMonsterSpawns = ticksPerMonsterSpawns; + } + + /** + * @return the maxCoopSize + */ + @Override + public int getMaxCoopSize() { + return maxCoopSize; + } + + /** + * @param maxCoopSize the maxCoopSize to set + */ + public void setMaxCoopSize(int maxCoopSize) { + this.maxCoopSize = maxCoopSize; + } + + /** + * @return the maxTrustSize + */ + @Override + public int getMaxTrustSize() { + return maxTrustSize; + } + + /** + * @param maxTrustSize the maxTrustSize to set + */ + public void setMaxTrustSize(int maxTrustSize) { + this.maxTrustSize = maxTrustSize; + } + + + + + + /** + * @return the defaultNewPlayerAction + */ + @Override + public String getDefaultNewPlayerAction() { + return defaultNewPlayerAction; + } + + /** + * @param defaultNewPlayerAction the defaultNewPlayerAction to set + */ + public void setDefaultNewPlayerAction(String defaultNewPlayerAction) { + this.defaultNewPlayerAction = defaultNewPlayerAction; + } + + /** + * @return the defaultPlayerAction + */ + @Override + public String getDefaultPlayerAction() { + return defaultPlayerAction; + } + + /** + * @param defaultPlayerAction the defaultPlayerAction to set + */ + public void setDefaultPlayerAction(String defaultPlayerAction) { + this.defaultPlayerAction = defaultPlayerAction; + } + + /** + * @return the mobLimitSettings + */ + @Override + public List getMobLimitSettings() { + return mobLimitSettings; + } + + /** + * @param mobLimitSettings the mobLimitSettings to set + */ + public void setMobLimitSettings(List mobLimitSettings) { + this.mobLimitSettings = mobLimitSettings; + } + + + + /** + * @return the defaultNetherBiome + */ + public Biome getDefaultNetherBiome() { + return defaultNetherBiome == null ? Biome.NETHER_WASTES : defaultNetherBiome; + } + + /** + * @param defaultNetherBiome the defaultNetherBiome to set + */ + public void setDefaultNetherBiome(Biome defaultNetherBiome) { + this.defaultNetherBiome = defaultNetherBiome; + } + + /** + * @return the defaultEndBiome + */ + public Biome getDefaultEndBiome() { + return defaultEndBiome == null ? Biome.THE_END : defaultEndBiome; + } + + /** + * @param defaultEndBiome the defaultEndBiome to set + */ + public void setDefaultEndBiome(Biome defaultEndBiome) { + this.defaultEndBiome = defaultEndBiome; + } + + /** + * @return the makeNetherPortals + */ + @Override + public boolean isMakeNetherPortals() { + return makeNetherPortals; + } + + /** + * @return the makeEndPortals + */ + @Override + public boolean isMakeEndPortals() { + return makeEndPortals; + } + + /** + * Sets make nether portals. + * @param makeNetherPortals the make nether portals + */ + public void setMakeNetherPortals(boolean makeNetherPortals) { + this.makeNetherPortals = makeNetherPortals; + } + + /** + * Sets make end portals. + * @param makeEndPortals the make end portals + */ + public void setMakeEndPortals(boolean makeEndPortals) { + this.makeEndPortals = makeEndPortals; + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /** + * @return the disallowTeamMemberIslands + */ + @Override + public boolean isDisallowTeamMemberIslands() { + return disallowTeamMemberIslands; + } + + /** + * @param disallowTeamMemberIslands the disallowTeamMemberIslands to set + */ + public void setDisallowTeamMemberIslands(boolean disallowTeamMemberIslands) { + this.disallowTeamMemberIslands = disallowTeamMemberIslands; + } + + + + /** + * @return the concurrentIslands + */ + @Override + public int getConcurrentIslands() { + if (concurrentIslands <= 0) { + return BentoBox.getInstance().getSettings().getIslandNumber(); + } + return concurrentIslands; + } + + /** + * @param concurrentIslands the concurrentIslands to set + */ + public void setConcurrentIslands(int concurrentIslands) { + this.concurrentIslands = concurrentIslands; + } + + // --------------------------------------------------------------------- + // TradeWinds-specific accessors + // --------------------------------------------------------------------- + + public long getGalaxySeed() { return galaxySeed; } + public void setGalaxySeed(long galaxySeed) { this.galaxySeed = galaxySeed; } + public int getGalaxyMinSeparation() { return galaxyMinSeparation; } + public void setGalaxyMinSeparation(int galaxyMinSeparation) { this.galaxyMinSeparation = galaxyMinSeparation; } + public int getStarterClusterRadius() { return starterClusterRadius; } + public void setStarterClusterRadius(int starterClusterRadius) { this.starterClusterRadius = starterClusterRadius; } + public int getStarterClusterMinIslands() { return starterClusterMinIslands; } + public void setStarterClusterMinIslands(int starterClusterMinIslands) { this.starterClusterMinIslands = starterClusterMinIslands; } + public double getFuelPerBlock() { return fuelPerBlock; } + public void setFuelPerBlock(double fuelPerBlock) { this.fuelPerBlock = fuelPerBlock; } + public double getWarpFailureChance() { return warpFailureChance; } + public void setWarpFailureChance(double warpFailureChance) { this.warpFailureChance = warpFailureChance; } + public boolean isIllegalTradeEnabled() { return illegalTradeEnabled; } + public void setIllegalTradeEnabled(boolean illegalTradeEnabled) { this.illegalTradeEnabled = illegalTradeEnabled; } + + public int getSeaFloor() { return seaFloor; } + public void setSeaFloor(int seaFloor) { this.seaFloor = seaFloor; } + public Material getWaterBlock() { return waterBlock == null ? Material.WATER : waterBlock; } + public void setWaterBlock(Material waterBlock) { this.waterBlock = waterBlock; } + public boolean isMakeCaves() { return makeCaves; } + public void setMakeCaves(boolean makeCaves) { this.makeCaves = makeCaves; } + public boolean isMakeDecorations() { return makeDecorations; } + public void setMakeDecorations(boolean makeDecorations) { this.makeDecorations = makeDecorations; } + public boolean isMakeStructures() { return makeStructures; } + public void setMakeStructures(boolean makeStructures) { this.makeStructures = makeStructures; } + public int getIntersticeSeaHeight() { return intersticeSeaHeight; } + public void setIntersticeSeaHeight(int intersticeSeaHeight) { this.intersticeSeaHeight = intersticeSeaHeight; } + public int getIntersticeSeaFloor() { return intersticeSeaFloor; } + public void setIntersticeSeaFloor(int intersticeSeaFloor) { this.intersticeSeaFloor = intersticeSeaFloor; } + public Material getIntersticeWaterBlock() { return intersticeWaterBlock == null ? Material.WATER : intersticeWaterBlock; } + public void setIntersticeWaterBlock(Material intersticeWaterBlock) { this.intersticeWaterBlock = intersticeWaterBlock; } + public Biome getDefaultAirBiome() { return defaultAirBiome == null ? Biome.OCEAN : defaultAirBiome; } + public void setDefaultAirBiome(Biome defaultAirBiome) { this.defaultAirBiome = defaultAirBiome; } + + public boolean isDebug() { return debug; } + public void setDebug(boolean debug) { this.debug = debug; } +} diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java new file mode 100644 index 0000000..0b7e54d --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -0,0 +1,181 @@ +package world.bentobox.tradewinds; + +import java.util.Objects; + +import org.bukkit.World; +import org.bukkit.World.Environment; +import org.bukkit.WorldCreator; +import org.bukkit.WorldType; +import org.bukkit.entity.SpawnCategory; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.ChunkGenerator; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.commands.admin.DefaultAdminCommand; +import world.bentobox.bentobox.api.commands.island.DefaultPlayerCommand; +import world.bentobox.bentobox.api.configuration.Config; +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.lists.Flags; +import world.bentobox.tradewinds.generator.ChunkGeneratorWorld; +import world.bentobox.tradewinds.generator.TradeWindsBiomeProvider; + +/** + * TradeWinds - a sea-trading game mode: an endless procedurally generated ocean + * of NPC trading islands. Buy low, sell high, smuggle, hunt bounties, turn pirate. + *

+ * The gamemode's "nether" world is the interstice: the hostile sea that + * failed warps drop players into. It has no portals and is unreachable until the + * warp system (Stage 5) sends players there. + * + * @author tastybento + */ +public class TradeWinds extends GameModeAddon { + + private static final String NETHER = "_nether"; + + private @Nullable Settings settings; + private @Nullable ChunkGenerator chunkGenerator; + private final Config configObject = new Config<>(this, Settings.class); + private BiomeProvider biomeProvider; + + /** + * This addon uses the new chunk generation API for the sea bottom + */ + @Override + public boolean isUsesNewChunkGeneration() { + return true; + } + + @Override + public void onLoad() { + // Save the default config from config.yml + saveDefaultConfig(); + // Load settings from config.yml. This will check if there are any issues with it too. + if (!loadSettings()) { + // Settings did not load - the addon has been disabled + return; + } + // Make the biome provider + this.biomeProvider = new TradeWindsBiomeProvider(this); + // Chunk generator + chunkGenerator = settings.isUseOwnGenerator() ? null : new ChunkGeneratorWorld(this); + // Register commands + playerCommand = new DefaultPlayerCommand(this) {}; + adminCommand = new DefaultAdminCommand(this) {}; + } + + private boolean loadSettings() { + settings = configObject.loadConfigObject(); + if (settings == null) { + // Woops + this.logError("TradeWinds settings could not load! Addon disabled."); + this.setState(State.DISABLED); + return false; + } + return true; + } + + @Override + public void onEnable() { + if (settings == null) { + return; + } + // Boats are the whole game - visitors must always be able to use them + Flags.BOAT.setDefaultSetting(islandWorld, true); + if (netherWorld != null) { + Flags.BOAT.setDefaultSetting(netherWorld, true); + } + } + + @Override + public void onDisable() { + // Nothing to do here yet + } + + @NonNull + public Settings getSettings() { + return Objects.requireNonNull(settings); + } + + @Override + public void createWorlds() { + String worldName = getSettings().getWorldName().toLowerCase(); + if (getServer().getWorld(worldName) == null) { + log("Creating TradeWinds ocean..."); + } + // Create the world if it does not exist + chunkGenerator = new ChunkGeneratorWorld(this); + islandWorld = getWorld(worldName, Environment.NORMAL); + // Make the interstice if it does not exist + if (getSettings().isNetherGenerate()) { + if (getServer().getWorld(worldName + NETHER) == null) { + log("Creating TradeWinds interstice..."); + } + netherWorld = getWorld(worldName, Environment.NETHER); + } + // No End world, ever: shulker shells must stay unobtainable so cargo + // expanders remain a purchase-only money sink (spec principle 7). + } + + /** + * Gets a world or generates a new world if it does not exist + * @param worldBaseName - the overworld name + * @param env - the environment + * @return world loaded or generated + */ + private World getWorld(String worldBaseName, Environment env) { + String name = env.equals(Environment.NETHER) ? worldBaseName + NETHER : worldBaseName; + WorldCreator wc = WorldCreator.name(name).environment(env).type(WorldType.NORMAL); + World w = getSettings().isUseOwnGenerator() ? wc.createWorld() : wc.generator(chunkGenerator).createWorld(); + if (w != null) { + configureSpawnRates(w); + } + return w; + } + + private void configureSpawnRates(World w) { + Settings s = getSettings(); + if (s.getSpawnLimitMonsters() > 0) w.setSpawnLimit(SpawnCategory.MONSTER, s.getSpawnLimitMonsters()); + if (s.getSpawnLimitAmbient() > 0) w.setSpawnLimit(SpawnCategory.AMBIENT, s.getSpawnLimitAmbient()); + if (s.getSpawnLimitAnimals() > 0) w.setSpawnLimit(SpawnCategory.ANIMAL, s.getSpawnLimitAnimals()); + if (s.getSpawnLimitWaterAnimals() > 0) w.setSpawnLimit(SpawnCategory.WATER_ANIMAL, s.getSpawnLimitWaterAnimals()); + if (s.getTicksPerAnimalSpawns() > 0) w.setTicksPerSpawns(SpawnCategory.ANIMAL, s.getTicksPerAnimalSpawns()); + if (s.getTicksPerMonsterSpawns() > 0) w.setTicksPerSpawns(SpawnCategory.MONSTER, s.getTicksPerMonsterSpawns()); + } + + @Override + public WorldSettings getWorldSettings() { + return getSettings(); + } + + @Override + public void onReload() { + if (loadSettings()) { + log("Reloaded TradeWinds settings"); + } + } + + @Override + public @NonNull ChunkGenerator getDefaultWorldGenerator(String worldName, String id) { + return chunkGenerator; + } + + @Override + public void saveWorldSettings() { + if (settings != null) { + configObject.saveConfigObject(settings); + } + } + + @Override + public void allLoaded() { + // Save settings. This will occur after all addons have loaded + this.saveWorldSettings(); + } + + public BiomeProvider getBiomeProvider() { + return this.biomeProvider; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/TradeWindsPladdon.java b/src/main/java/world/bentobox/tradewinds/TradeWindsPladdon.java new file mode 100644 index 0000000..cac269c --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/TradeWindsPladdon.java @@ -0,0 +1,20 @@ +package world.bentobox.tradewinds; + +import world.bentobox.bentobox.api.addons.Addon; +import world.bentobox.bentobox.api.addons.Pladdon; + +/** + * Pladdon wrapper so TradeWinds can load as a standard Bukkit plugin. + */ +public class TradeWindsPladdon extends Pladdon { + + private Addon addon; + + @Override + public Addon getAddon() { + if (addon == null) { + addon = new TradeWinds(); + } + return addon; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java new file mode 100644 index 0000000..0101262 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -0,0 +1,164 @@ +package world.bentobox.tradewinds.generator; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Random; + +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.World.Environment; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; +import org.bukkit.util.noise.PerlinOctaveGenerator; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Generates the TradeWinds ocean: a noised ocean floor under open sea, everywhere. + *

+ * Both the overworld and the interstice (NETHER environment) use this generator; + * only the sea levels, water block and floor palette differ. Vanilla noise is off + * so no vanilla continents can appear: from Stage 1 the only land in the world + * comes from the seeded galaxy's radial island masks via {@link #terrainScale}. + * + * @author tastybento + */ +public class ChunkGeneratorWorld extends ChunkGenerator { + + /** + * Floor palette: base fill under the surface layer, and the two materials the + * noised floor surface is randomly built from. + */ + private record FloorMats(Material deepBase, Material base, Material top) { + } + + /** + * Per-environment sea shape. + */ + private record WorldConfig(int seaHeight, int seaFloor, Material waterBlock) { + } + + /** Maximum height variation of the ocean floor noise, in blocks. */ + private static final int NOISE_MAX = 25; + private static final double NOISE_SCALE = 1.0 / 30.0; + private static final int NOISE_OCTAVES = 8; + + private final TradeWinds addon; + private final Map seaConfig = new EnumMap<>(Environment.class); + private static final Map FLOOR_MATS = Map.of( + Environment.NORMAL, new FloorMats(Material.STONE, Material.SANDSTONE, Material.SAND), + Environment.NETHER, new FloorMats(Material.NETHERRACK, Material.BASALT, Material.SOUL_SAND)); + + private final Map noiseGens = new EnumMap<>(Environment.class); + // Deterministic palette randomness; re-seeded per chunk from (world seed, chunk coords) + private final Random rand = new Random(); + + public ChunkGeneratorWorld(TradeWinds addon) { + super(); + this.addon = addon; + seaConfig.put(Environment.NORMAL, new WorldConfig(addon.getSettings().getSeaHeight(), + addon.getSettings().getSeaFloor(), addon.getSettings().getWaterBlock())); + seaConfig.put(Environment.NETHER, new WorldConfig(addon.getSettings().getIntersticeSeaHeight(), + addon.getSettings().getIntersticeSeaFloor(), addon.getSettings().getIntersticeWaterBlock())); + } + + /** + * Terrain amplitude multiplier at a world column. Stage 0: flat ocean everywhere, + * so always 1. Stage 1 multiplies in the radial island mask here: >1 near island + * centers lifts the floor above sea level; 1 is plain ocean floor. + * + * @param worldInfo world being generated + * @param worldX world x of the column + * @param worldZ world z of the column + * @return amplitude multiplier, >= 0 + */ + protected double terrainScale(WorldInfo worldInfo, int worldX, int worldZ) { + return 1.0; + } + + @Override + public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, int chunkX, int chunkZ, + @NonNull ChunkData chunkData) { + WorldConfig wc = seaConfig.get(worldInfo.getEnvironment()); + if (wc == null) { + return; // Only NORMAL and NETHER are ever created + } + FloorMats mats = FLOOR_MATS.get(worldInfo.getEnvironment()); + PerlinOctaveGenerator gen = noiseGens.computeIfAbsent(worldInfo.getEnvironment(), env -> { + PerlinOctaveGenerator g = new PerlinOctaveGenerator(worldInfo.getSeed(), NOISE_OCTAVES); + g.setScale(NOISE_SCALE); + return g; + }); + // Deterministic per-chunk palette randomness so regeneration is identical + rand.setSeed(worldInfo.getSeed() ^ (chunkX * 341873128712L + chunkZ * 132897987541L)); + + int minHeight = worldInfo.getMinHeight(); + // Bedrock floor + chunkData.setRegion(0, minHeight, 0, 16, minHeight + 1, 16, Material.BEDROCK); + // Solid base up to the sea floor + if (wc.seaFloor() > minHeight + 1) { + chunkData.setRegion(0, minHeight + 1, 0, 16, wc.seaFloor(), 16, mats.deepBase()); + } + // Noised floor surface, then water up to sea level + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int worldX = (chunkX << 4) + x; + int worldZ = (chunkZ << 4) + z; + double noiseVal = gen.noise(worldX, worldZ, 0.5, 0.5, true); + double scale = terrainScale(worldInfo, worldX, worldZ); + int floorTop = wc.seaFloor() + (int) ((NOISE_MAX + NOISE_MAX * noiseVal) * scale); + floorTop = Math.min(floorTop, worldInfo.getMaxHeight() - 1); + for (int y = wc.seaFloor(); y < floorTop; y++) { + chunkData.setBlock(x, y, z, rand.nextBoolean() ? mats.top() : mats.base()); + } + // Water column above the floor + for (int y = Math.max(floorTop, wc.seaFloor()); y <= wc.seaHeight(); y++) { + chunkData.setBlock(x, y, z, wc.waterBlock()); + } + } + } + } + + @Override + public boolean shouldGenerateNoise() { + // No vanilla terrain: land only ever comes from the island masks + return false; + } + + @Override + public boolean shouldGenerateSurface() { + return false; + } + + @Override + public boolean shouldGenerateCaves() { + return addon.getSettings().isMakeCaves(); + } + + @Override + public boolean shouldGenerateDecorations() { + return addon.getSettings().isMakeDecorations(); + } + + @Override + public boolean shouldGenerateMobs() { + return true; + } + + @Override + public boolean shouldGenerateStructures() { + return addon.getSettings().isMakeStructures(); + } + + @Override + public BiomeProvider getDefaultBiomeProvider(WorldInfo worldInfo) { + return addon.getBiomeProvider(); + } + + @Override + public boolean canSpawn(World world, int x, int z) { + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java b/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java new file mode 100644 index 0000000..c3d4415 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java @@ -0,0 +1,43 @@ +package world.bentobox.tradewinds.generator; + +import java.util.List; + +import org.bukkit.block.Biome; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.WorldInfo; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Biomes for the TradeWinds worlds. Stage 0: uniform ocean (sea biome at and + * below sea level, air biome above) and a single interstice biome. Stage 1 adds + * per-island whole-island biomes from the seeded galaxy. + * + * @author tastybento + */ +public class TradeWindsBiomeProvider extends BiomeProvider { + + private final TradeWinds addon; + + public TradeWindsBiomeProvider(TradeWinds addon) { + this.addon = addon; + } + + @Override + public Biome getBiome(WorldInfo worldInfo, int x, int y, int z) { + return switch (worldInfo.getEnvironment()) { + case NETHER -> addon.getSettings().getDefaultNetherBiome(); + default -> y <= addon.getSettings().getSeaHeight() + ? addon.getSettings().getDefaultBiome() + : addon.getSettings().getDefaultAirBiome(); + }; + } + + @Override + public List getBiomes(WorldInfo worldInfo) { + return switch (worldInfo.getEnvironment()) { + case NETHER -> List.of(addon.getSettings().getDefaultNetherBiome()); + default -> List.of(addon.getSettings().getDefaultBiome(), addon.getSettings().getDefaultAirBiome()); + }; + } +} diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml new file mode 100644 index 0000000..e1c452f --- /dev/null +++ b/src/main/resources/addon.yml @@ -0,0 +1,29 @@ +name: TradeWinds +main: world.bentobox.tradewinds.TradeWinds +version: ${version}${build.number} +api-version: 3.18.0 +metrics: true +icon: "OAK_CHEST_BOAT" +repository: "BentoBoxWorld/TradeWinds" +authors: tastybento +softdepend: BlueBook, Border, Level, Challenges, Visit, Warps + +permissions: + tradewinds.island: + description: Allow use of the TradeWinds player command + default: true + tradewinds.island.spawn: + description: Allow teleporting to the TradeWinds spawn + default: true + tradewinds.island.info: + description: Allow use of the island info command + default: true + tradewinds.island.language: + description: Allow use of the language command + default: true + tradewinds.admin: + description: Allow use of the TradeWinds admin command + default: op + tradewinds.admin.*: + description: All TradeWinds admin permissions + default: op diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..50bf8b5 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,76 @@ +# TradeWinds Configuration ${version} +tradewinds: + command: + # Player command. What command users will run to access the game mode. + island: tw tradewinds + # The admin command. + admin: twadmin + # The default action for new player command call. + # TradeWinds players do not start with an island, so the default is 'spawn'. + new-player-action: spawn + # The default action for player command. + default-action: spawn +galaxy: + # The galaxy seed. Every island position, type, security band, biome, name and + # route cost derives deterministically from this one number - share it and another + # server gets the same trading galaxy. 0 means: use the world seed. + seed: 0 + # Minimum separation between trading island centers in blocks. + min-separation: 2500 + # Radius in blocks around spawn inside which the starter-cluster density floor applies. + starter-cluster-radius: 5000 + # Minimum number of trading islands guaranteed inside the starter cluster radius. + starter-cluster-min-islands: 5 +travel: + warp: + # Base warp fuel cost multiplier: fuel units per block of Euclidean route distance. + fuel-per-block: 0.01 + # Chance (0.0-1.0) that a warp fails and drops the player into the interstice. + # Re-engaging the warp from the interstice to the original destination is always free. + failure-chance: 0.05 +illegal-trade: + # Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling. + # Set false for family-friendly servers - removes the entire crime layer cleanly. + enabled: true +world: + friendly-name: TradeWinds + world-name: tradewinds_world + difficulty: NORMAL + # Radius of island space in blocks (island "range"). Islands are never in sight of each other. + distance-between-islands: 1000 + # Protection radius of trading islands in blocks. + protection-range: 400 + start-x: 0 + start-z: 0 + island-height: 70 + use-own-generator: false + # Sea surface level. + sea-height: 70 + # Base y below which the ocean floor terrain noise starts. + sea-floor: 25 + # Water block for the overworld ocean. + water-block: WATER + # Allow vanilla cave generation under the ocean floor. + make-caves: false + # Allow vanilla decoration (kelp, seagrass, trees on island land). + make-decorations: true + # Allow vanilla structure generation (shipwrecks, ruins...). + make-structures: false + max-islands: -1 + default-game-mode: SURVIVAL + nether: + # The interstice: the hostile Nether-environment sea that failed warps drop players + # into. Unreachable by any portal - warp failure is the only way in. + generate: true + islands: true + sea-height: 70 + sea-floor: 25 + water-block: WATER + create-and-link-portals: false + end: + # Never enable: no End means shulker shells stay unobtainable, keeping cargo + # expanders a purchase-only money sink. + generate: false + islands: false +# Log detailed [TradeWinds DEBUG] lines around galaxy generation, travel and trade. +debug: false diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml new file mode 100644 index 0000000..098fe10 --- /dev/null +++ b/src/main/resources/locales/en-US.yml @@ -0,0 +1,19 @@ +--- +########################################################################################### +# This is a YML file. Be careful when editing. Check your edits in a YAML checker like # +# the one at http://yaml-online-parser.appspot.com # +########################################################################################### + +tradewinds: + general: + worlds: + overworld: "the open ocean" + interstice: "the interstice" + commands: + admin: + help: + description: "admin command for TradeWinds" + help: + description: "TradeWinds trading command" + spawn: + teleported: "&b You set sail on the open ocean." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..92d91be --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,9 @@ +name: BentoBox-TradeWinds +main: world.bentobox.tradewinds.TradeWindsPladdon +version: ${project.version}${build.number} +api-version: "1.21" + +authors: [tastybento] +contributors: ["The BentoBoxWorld Community"] +website: https://bentobox.world +description: ${project.description} diff --git a/src/test/java/net/kyori/adventure/util/Buildable.java b/src/test/java/net/kyori/adventure/util/Buildable.java new file mode 100644 index 0000000..ff0a442 --- /dev/null +++ b/src/test/java/net/kyori/adventure/util/Buildable.java @@ -0,0 +1,37 @@ +package net.kyori.adventure.util; + +/** + * ABI STUB - test classpath only. + *

+ * Adventure 4.x had this interface; Adventure 5 (shipped by Paper 26.2) + * removed it. MockBukkit 26.1.2 classes (BookMetaMock, BlockMock) still + * reference it in their signatures, so loading them under Adventure 5 throws + * {@link NoClassDefFoundError}. This empty stand-in lets those classes link; + * only a code path that actually *invokes* a removed Adventure 4 method would + * still fail, and none of Gusher's tests do. + *

+ * Delete together with the other MockBukkit 26.2 shims (see + * scripts/build_patched_mockbukkit.py) when MockBukkit ships a 26.2 build. + * + * @param the type built by the builder + * @param the builder type + */ +public interface Buildable> { + + /** + * @return a builder based on this instance + */ + B toBuilder(); + + /** + * The builder. + * + * @param the built type + */ + interface Builder { + /** + * @return the built instance + */ + R build(); + } +} diff --git a/src/test/java/org/mockbukkit/mockbukkit/adventure/PlainTextComponentProviderImpl.java b/src/test/java/org/mockbukkit/mockbukkit/adventure/PlainTextComponentProviderImpl.java new file mode 100644 index 0000000..2fdfe7b --- /dev/null +++ b/src/test/java/org/mockbukkit/mockbukkit/adventure/PlainTextComponentProviderImpl.java @@ -0,0 +1,56 @@ +package org.mockbukkit.mockbukkit.adventure; + +import java.util.function.Consumer; + +import org.jetbrains.annotations.NotNull; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.flattener.ComponentFlattener; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +/** + * CLASSPATH SHADOW of MockBukkit's PlainTextComponentSerializer provider. + *

+ * MockBukkit v26.1.2 (no 26.2 build exists yet) was compiled against Adventure + * 4.x; Paper API 26.2 ships Adventure 5.2.0, where + * {@code ComponentFlattener.toBuilder()} changed signature, so the original + * class dies with {@link NoSuchMethodError} during BentoBox's + * {@code Util.}. Because {@code target/test-classes} precedes the + * MockBukkit jar on the test classpath, this Adventure-5-compatible copy is + * loaded instead. + *

+ * Delete together with src/test/resources/keyed and /registries when a + * MockBukkit build for 26.2+ is released. + */ +public class PlainTextComponentProviderImpl implements PlainTextComponentSerializer.Provider { + + /** + * A minimal serializer that flattens components to plain text. Built + * directly (not via {@code PlainTextComponentSerializer.builder()}) to + * avoid re-entrant initialization of the serializer's service holder. + */ + private static final PlainTextComponentSerializer SIMPLE = new PlainTextComponentSerializer() { + + @Override + public void serialize(@NotNull StringBuilder sb, @NotNull Component component) { + ComponentFlattener.basic().flatten(component, sb::append); + } + + @Override + public @NotNull Builder toBuilder() { + throw new UnsupportedOperationException("Shadow serializer does not support toBuilder()"); + } + }; + + @Override + public @NotNull PlainTextComponentSerializer plainTextSimple() { + return SIMPLE; + } + + @Override + public @NotNull Consumer plainText() { + return builder -> { + // No customization + }; + } +} diff --git a/src/test/java/world/bentobox/tradewinds/CommonTestSetup.java b/src/test/java/world/bentobox/tradewinds/CommonTestSetup.java new file mode 100644 index 0000000..8365080 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/CommonTestSetup.java @@ -0,0 +1,301 @@ +package world.bentobox.tradewinds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.entity.Player.Spigot; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.inventory.ItemFactory; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.plugin.PluginManager; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.stubbing.Answer; + +import com.google.common.collect.ImmutableSet; + +import net.md_5.bungee.api.chat.TextComponent; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.user.Notifier; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.database.objects.Players; +import world.bentobox.bentobox.managers.BlueprintsManager; +import world.bentobox.bentobox.managers.FlagsManager; +import world.bentobox.bentobox.managers.HooksManager; +import world.bentobox.bentobox.managers.IslandWorldManager; +import world.bentobox.bentobox.managers.IslandsManager; +import world.bentobox.bentobox.managers.LocalesManager; +import world.bentobox.bentobox.managers.PlaceholdersManager; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.util.Util; + +/** + * Common items for testing. Don't forget to use super.setUp()! + *

+ * Sets up BentoBox plugin, pluginManager and ItemFactory. + * Location, world, playersManager and player. + * IWM, Addon and WorldSettings. IslandManager with one + * island with protection and nothing allowed by default. + * Owner of island is player with same UUID. + * Locales, placeholders. + * @author tastybento + * + */ +public abstract class CommonTestSetup { + + protected UUID uuid = UUID.randomUUID(); + + @Mock + protected Player mockPlayer; + @Mock + protected PluginManager pim; + @Mock + protected ItemFactory itemFactory; + @Mock + protected Location location; + @Mock + protected World world; + @Mock + protected IslandWorldManager iwm; + @Mock + protected IslandsManager im; + @Mock + protected Island island; + @Mock + protected BentoBox plugin; + @Mock + protected PlayerInventory inv; + @Mock + protected Notifier notifier; + @Mock + protected FlagsManager fm; + @Mock + protected Spigot spigot; + @Mock + protected HooksManager hooksManager; + @Mock + protected BlueprintsManager bm; + + protected ServerMock server; + + protected MockedStatic mockedBukkit; + protected MockedStatic mockedUtil; + + protected AutoCloseable closeable; + + @Mock + protected BukkitScheduler sch; + @Mock + protected LocalesManager lm; + + @Mock + protected PlaceholdersManager phm; + + + @SuppressWarnings("java:S1130") // subclasses override and declare checked exceptions + @BeforeEach + public void setUp() throws Exception { + // Processes the @Mock annotations and initializes the field + closeable = MockitoAnnotations.openMocks(this); + server = MockBukkit.mock(); + // Bukkit + // Set up plugin + WhiteBox.setInternalState(BentoBox.class, "instance", plugin); + + // Force Tag static fields (e.g. Tag.LEAVES) to be initialized NOW, while the + // real MockBukkit server is active, rather than later when mockedBukkit is in + // place. If Tag fields load under mockedBukkit (RETURNS_DEEP_STUBS), they + // become Mockito deep-stub mocks that stale across tests after clearInlineMocks(). + @SuppressWarnings("unused") + var unusedTagRef = org.bukkit.Tag.LEAVES; + + // Register the static mock + mockedBukkit = Mockito.mockStatic(Bukkit.class, Mockito.RETURNS_DEEP_STUBS); + mockedBukkit.when(Bukkit::getMinecraftVersion).thenReturn("1.21.10"); + mockedBukkit.when(Bukkit::getBukkitVersion).thenReturn(""); + mockedBukkit.when(Bukkit::getPluginManager).thenReturn(pim); + mockedBukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); + mockedBukkit.when(Bukkit::getServer).thenReturn(server); + // Location + when(location.getWorld()).thenReturn(world); + when(location.getBlockX()).thenReturn(0); + when(location.getBlockY()).thenReturn(0); + when(location.getBlockZ()).thenReturn(0); + when(location.toVector()).thenReturn(new Vector(0,0,0)); + when(location.clone()).thenReturn(location); // Paper + + // Players Manager and meta data + PlayersManager pm = mock(PlayersManager.class); + when(plugin.getPlayers()).thenReturn(pm); + Players players = mock(Players.class); + when(players.getMetaData()).thenReturn(Optional.empty()); + when(pm.getPlayer(any(UUID.class))).thenReturn(players); + + // Player + when(mockPlayer.getUniqueId()).thenReturn(uuid); + when(mockPlayer.getLocation()).thenReturn(location); + when(mockPlayer.getWorld()).thenReturn(world); + when(mockPlayer.getName()).thenReturn("tastybento"); + when(mockPlayer.getInventory()).thenReturn(inv); + when(mockPlayer.spigot()).thenReturn(spigot); + when(mockPlayer.getType()).thenReturn(EntityType.PLAYER); + when(mockPlayer.getWorld()).thenReturn(world); + + User.setPlugin(plugin); + User.clearUsers(); + User.getInstance(mockPlayer); + + // IWM + when(plugin.getIWM()).thenReturn(iwm); + when(iwm.inWorld(any(Location.class))).thenReturn(true); + when(iwm.inWorld(any(World.class))).thenReturn(true); + when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); + // Addon + when(iwm.getAddon(any())).thenReturn(Optional.empty()); + + // World Settings + WorldSettings worldSet = new TestWorldSettings(); + when(iwm.getWorldSettings(any())).thenReturn(worldSet); + + // Island Manager + when(plugin.getIslands()).thenReturn(im); + Optional optionalIsland = Optional.of(island); + when(im.getProtectedIslandAt(any())).thenReturn(optionalIsland); + + // Island - nothing is allowed by default + when(island.isAllowed(any())).thenReturn(false); + when(island.isAllowed(any(User.class), any())).thenReturn(false); + when(island.getOwner()).thenReturn(uuid); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid)); + + // Enable reporting from Flags class + @SuppressWarnings("deprecation") + MetadataValue mdv = new FixedMetadataValue(plugin, "_why_debug"); + when(mockPlayer.getMetadata(anyString())).thenReturn(Collections.singletonList(mdv)); + + // Locales & Placeholders + when(lm.get(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + when(plugin.getPlaceholdersManager()).thenReturn(phm); + when(phm.replacePlaceholders(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + when(plugin.getLocalesManager()).thenReturn(lm); + // Notifier + when(plugin.getNotifier()).thenReturn(notifier); + + // Fake players + world.bentobox.bentobox.Settings settings = new world.bentobox.bentobox.Settings(); + when(plugin.getSettings()).thenReturn(settings); + + //Util + mockedUtil = Mockito.mockStatic(Util.class, Mockito.CALLS_REAL_METHODS); + mockedUtil.when(() -> Util.getWorld(any())).thenReturn(mock(World.class)); + Util.setPlugin(plugin); + + // Util + mockedUtil.when(() -> Util.findFirstMatchingEnum(any(), any())).thenCallRealMethod(); + // Server & Scheduler + mockedBukkit.when(Bukkit::getScheduler).thenReturn(sch); + + // Hooks + when(hooksManager.getHook(anyString())).thenReturn(Optional.empty()); + when(plugin.getHooks()).thenReturn(hooksManager); + + // Blueprints Manager + when(plugin.getBlueprintsManager()).thenReturn(bm); + } + + /** + */ + @AfterEach + public void tearDown() throws Exception { + // IMPORTANT: Explicitly close the mock to prevent leakage + mockedBukkit.closeOnDemand(); + mockedUtil.closeOnDemand(); + closeable.close(); + MockBukkit.unmock(); + User.clearUsers(); + Mockito.framework().clearInlineMocks(); + deleteAll(new File("database")); + deleteAll(new File("database_backup")); + } + + protected static void deleteAll(File file) throws IOException { + if (file.exists()) { + Files.walk(file.toPath()).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } + + } + + /** + * Check that spigot sent the message + * @param expectedMessage - message to check + */ + public void checkSpigotMessage(String expectedMessage) { + checkSpigotMessage(expectedMessage, 1); + } + + @SuppressWarnings("deprecation") + public void checkSpigotMessage(String expectedMessage, int expectedOccurrences) { + // Capture the argument passed to spigot().sendMessage(...) if messages are sent + ArgumentCaptor captor = ArgumentCaptor.forClass(TextComponent.class); + + // Verify that sendMessage() was called at least 0 times (capture any sent messages) + verify(spigot, atLeast(0)).sendMessage(captor.capture()); + + // Get all captured TextComponents + List capturedMessages = captor.getAllValues(); + + // Count the number of occurrences of the expectedMessage in the captured messages + // NOSONAR S1612: BaseComponent overloads toLegacyText() (instance) and toLegacyText(BaseComponent...) (static), + // so a method reference is ambiguous and will not compile; the lambda is required. + long actualOccurrences = capturedMessages.stream().map(component -> component.toLegacyText()) // NOSONAR + .filter(messageText -> messageText.contains(expectedMessage)) // Check if the message contains the expected text + .count(); // Count how many times the expected message appears + + // Assert that the number of occurrences matches the expectedOccurrences + assertEquals(expectedOccurrences, + actualOccurrences, "Expected message occurrence mismatch: " + expectedMessage); + } + + /** + * Get the exploded event + */ + public EntityExplodeEvent getExplodeEvent(Entity entity, Location l, List list) { + return new EntityExplodeEvent(entity, l, list, 0, org.bukkit.ExplosionResult.DESTROY); + } + +} diff --git a/src/test/java/world/bentobox/tradewinds/SettingsTest.java b/src/test/java/world/bentobox/tradewinds/SettingsTest.java new file mode 100644 index 0000000..a170a86 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/SettingsTest.java @@ -0,0 +1,85 @@ +package world.bentobox.tradewinds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.bukkit.Material; +import org.bukkit.block.Biome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests the Stage 0 defaults of {@link Settings}. + * + * @author tastybento + */ +class SettingsTest extends CommonTestSetup { + + private Settings settings; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + settings = new Settings(); + } + + @Test + void testCommands() { + assertEquals("tw tradewinds", settings.getPlayerCommandAliases()); + assertEquals("twadmin", settings.getAdminCommandAliases()); + // No starting island: both default actions teleport to spawn + assertEquals("spawn", settings.getDefaultNewPlayerAction()); + assertEquals("spawn", settings.getDefaultPlayerAction()); + } + + @Test + void testWorldDefaults() { + assertEquals("TradeWinds", settings.getFriendlyName()); + assertEquals("tradewinds_world", settings.getWorldName()); + assertEquals("tradewinds", settings.getPermissionPrefix()); + assertEquals(1000, settings.getIslandDistance()); + assertEquals(400, settings.getIslandProtectionRange()); + assertEquals(70, settings.getSeaHeight()); + assertEquals(25, settings.getSeaFloor()); + assertEquals(Material.WATER, settings.getWaterBlock()); + assertEquals(Biome.OCEAN, settings.getDefaultBiome()); + assertEquals(Biome.OCEAN, settings.getDefaultAirBiome()); + } + + @Test + void testIntersticeDefaults() { + // The interstice is on by default and reachable only via warp failure + assertTrue(settings.isNetherGenerate()); + assertTrue(settings.isNetherIslands()); + assertFalse(settings.isMakeNetherPortals()); + assertEquals(70, settings.getIntersticeSeaHeight()); + assertEquals(25, settings.getIntersticeSeaFloor()); + assertEquals(Material.WATER, settings.getIntersticeWaterBlock()); + assertEquals(Biome.NETHER_WASTES, settings.getDefaultNetherBiome()); + } + + @Test + void testNoEndWorld() { + // Spec principle 7: no End -> shulker shells unobtainable -> cargo expanders stay a money sink + assertFalse(settings.isEndGenerate()); + assertFalse(settings.isEndIslands()); + } + + @Test + void testGalaxyDefaults() { + assertEquals(0, settings.getGalaxySeed()); + assertTrue(settings.getGalaxyMinSeparation() >= 2 * settings.getIslandDistance()); + assertEquals(5000, settings.getStarterClusterRadius()); + assertEquals(5, settings.getStarterClusterMinIslands()); + } + + @Test + void testGameplayGates() { + assertTrue(settings.isIllegalTradeEnabled()); + assertEquals(0.01, settings.getFuelPerBlock()); + assertEquals(0.05, settings.getWarpFailureChance()); + assertFalse(settings.isDebug()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/TestWorldSettings.java b/src/test/java/world/bentobox/tradewinds/TestWorldSettings.java new file mode 100644 index 0000000..cb18c37 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/TestWorldSettings.java @@ -0,0 +1,409 @@ +package world.bentobox.tradewinds; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Difficulty; +import org.bukkit.GameMode; +import org.bukkit.entity.EntityType; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.flags.Flag; + +/** + * Class for tests that require world settings + * @author tastybento + * + */ +public class TestWorldSettings implements WorldSettings { + + private long epoch; + + @Override + public GameMode getDefaultGameMode() { + + return GameMode.SURVIVAL; + } + + @Override + public Map getDefaultIslandFlags() { + + return Collections.emptyMap(); + } + + @Override + public Map getDefaultIslandSettings() { + + return Collections.emptyMap(); + } + + @Override + public Difficulty getDifficulty() { + + return Difficulty.EASY; + } + + @Override + public void setDifficulty(Difficulty difficulty) { + // Do nothing + + } + + @Override + public String getFriendlyName() { + + return "friendly_name"; + } + + @Override + public int getIslandDistance() { + + return 0; + } + + @Override + public int getIslandHeight() { + + return 0; + } + + @Override + public int getIslandProtectionRange() { + + return 0; + } + + @Override + public int getIslandStartX() { + + return 0; + } + + @Override + public int getIslandStartZ() { + + return 0; + } + + @Override + public int getIslandXOffset() { + + return 0; + } + + @Override + public int getIslandZOffset() { + + return 0; + } + + @Override + public List getIvSettings() { + + return Collections.emptyList(); + } + + @Override + public int getMaxHomes() { + + return 3; + } + + @Override + public int getMaxIslands() { + + return 0; + } + + @Override + public int getMaxTeamSize() { + + return 4; + } + + @Override + public int getNetherSpawnRadius() { + + return 10; + } + + @Override + public String getPermissionPrefix() { + + return "perm."; + } + + @Override + public Set getRemoveMobsWhitelist() { + + return Collections.emptySet(); + } + + @Override + public int getSeaHeight() { + + return 0; + } + + @Override + public List getHiddenFlags() { + + return Collections.emptyList(); + } + + @Override + public List getVisitorBannedCommands() { + + return Collections.emptyList(); + } + + @Override + public Map getWorldFlags() { + + return Collections.emptyMap(); + } + + @Override + public String getWorldName() { + + return "world_name"; + } + + @Override + public boolean isDragonSpawn() { + + return false; + } + + @Override + public boolean isEndGenerate() { + + return true; + } + + @Override + public boolean isEndIslands() { + + return true; + } + + @Override + public boolean isNetherGenerate() { + + return true; + } + + @Override + public boolean isNetherIslands() { + + return true; + } + + @Override + public boolean isOnJoinResetEnderChest() { + + return false; + } + + @Override + public boolean isOnJoinResetInventory() { + + return false; + } + + @Override + public boolean isOnJoinResetMoney() { + + return false; + } + + @Override + public boolean isOnJoinResetHealth() { + + return false; + } + + @Override + public boolean isOnJoinResetHunger() { + + return false; + } + + @Override + public boolean isOnJoinResetXP() { + + return false; + } + + @Override + public @NonNull List getOnJoinCommands() { + + return Collections.emptyList(); + } + + @Override + public boolean isOnLeaveResetEnderChest() { + + return false; + } + + @Override + public boolean isOnLeaveResetInventory() { + + return false; + } + + @Override + public boolean isOnLeaveResetMoney() { + + return false; + } + + @Override + public boolean isOnLeaveResetHealth() { + + return false; + } + + @Override + public boolean isOnLeaveResetHunger() { + + return false; + } + + @Override + public boolean isOnLeaveResetXP() { + + return false; + } + + @Override + public @NonNull List getOnLeaveCommands() { + + return Collections.emptyList(); + } + + @Override + public boolean isUseOwnGenerator() { + + return false; + } + + @Override + public boolean isWaterUnsafe() { + + return false; + } + + @Override + public List getGeoLimitSettings() { + + return Collections.emptyList(); + } + + @Override + public int getResetLimit() { + + return 0; + } + + @Override + public long getResetEpoch() { + + return epoch; + } + + @Override + public void setResetEpoch(long timestamp) { + this.epoch = timestamp; + + } + + @Override + public boolean isTeamJoinDeathReset() { + + return false; + } + + @Override + public int getDeathsMax() { + + return 0; + } + + @Override + public boolean isDeathsCounted() { + + return true; + } + + @Override + public boolean isDeathsResetOnNewIsland() { + + return true; + } + + @Override + public boolean isAllowSetHomeInNether() { + + return false; + } + + @Override + public boolean isAllowSetHomeInTheEnd() { + + return false; + } + + @Override + public boolean isRequireConfirmationToSetHomeInNether() { + + return false; + } + + @Override + public boolean isRequireConfirmationToSetHomeInTheEnd() { + + return false; + } + + @Override + public int getBanLimit() { + + return 10; + } + + @Override + public boolean isLeaversLoseReset() { + + return true; + } + + @Override + public boolean isKickedKeepInventory() { + + return true; + } + + @Override + public boolean isCreateIslandOnFirstLoginEnabled() { + + return false; + } + + @Override + public int getCreateIslandOnFirstLoginDelay() { + + return 0; + } + + @Override + public boolean isCreateIslandOnFirstLoginAbortOnLogout() { + + return false; + } + +} diff --git a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java new file mode 100644 index 0000000..d5ea9c4 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java @@ -0,0 +1,213 @@ +package world.bentobox.tradewinds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +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 java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import world.bentobox.bentobox.Settings; +import world.bentobox.bentobox.api.addons.Addon.State; +import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.DatabaseSetup; +import world.bentobox.bentobox.database.DatabaseSetup.DatabaseType; +import world.bentobox.bentobox.managers.AddonsManager; +import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.bentobox.managers.FlagsManager; + +/** + * Tests the TradeWinds addon lifecycle. + * + * @author tastybento + */ +class TradeWindsTest extends CommonTestSetup { + + private TradeWinds addon; + @Mock + private FlagsManager flagsManager; + @Mock + private Settings pluginSettings; + + private MockedStatic mockDb; + + @Override + @AfterEach + public void tearDown() throws Exception { + mockDb.closeOnDemand(); + super.tearDown(); + deleteAll(new File("database")); + deleteAll(new File("database_backup")); + new File("config.yml").delete(); + new File("addon.jar").delete(); + deleteAll(new File("addons")); + } + + @SuppressWarnings("unchecked") + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + // Database + AbstractDatabaseHandler h = mock(AbstractDatabaseHandler.class); + mockDb = Mockito.mockStatic(DatabaseSetup.class); + DatabaseSetup dbSetup = mock(DatabaseSetup.class); + mockDb.when(DatabaseSetup::getDatabase).thenReturn(dbSetup); + when(dbSetup.getHandler(any())).thenReturn(h); + when(h.saveObject(any())).thenReturn(CompletableFuture.completedFuture(true)); + + // The database type has to be created one line before the thenReturn() to work! + DatabaseType value = DatabaseType.JSON; + when(plugin.getSettings()).thenReturn(pluginSettings); + when(pluginSettings.getDatabaseType()).thenReturn(value); + + // Command manager + CommandsManager cm = mock(CommandsManager.class); + when(plugin.getCommandsManager()).thenReturn(cm); + + // Create the addon with a temporary jar containing config.yml + addon = new TradeWinds(); + File jFile = new File("addon.jar"); + try (JarOutputStream tempJarOutputStream = new JarOutputStream(new FileOutputStream(jFile))) { + Path fromPath = Paths.get("src/main/resources/config.yml"); + Path path = Paths.get("config.yml"); + Files.copy(fromPath, path); + add(path, tempJarOutputStream); + } + + File dataFolder = new File("addons/TradeWinds"); + addon.setDataFolder(dataFolder); + addon.setFile(jFile); + AddonDescription desc = new AddonDescription.Builder("bentobox", "tradewinds", "0.1.0").description("test") + .authors("tastybento").build(); + addon.setDescription(desc); + // Addons manager + AddonsManager am = mock(AddonsManager.class); + when(plugin.getAddonsManager()).thenReturn(am); + + // Flags manager + when(plugin.getFlagsManager()).thenReturn(flagsManager); + when(flagsManager.getFlags()).thenReturn(Collections.emptyList()); + } + + private void add(Path path, JarOutputStream tempJarOutputStream) throws IOException { + try (FileInputStream fis = new FileInputStream(path.toFile())) { + byte[] buffer = new byte[1024]; + int bytesRead = 0; + JarEntry entry = new JarEntry(path.toString()); + tempJarOutputStream.putNextEntry(entry); + while ((bytesRead = fis.read(buffer)) != -1) { + tempJarOutputStream.write(buffer, 0, bytesRead); + } + } + } + + /** + * Test method for {@link TradeWinds#onLoad()}. + */ + @Test + void testOnLoad() { + addon.onLoad(); + // Check that config.yml file has been saved + File check = new File("addons/TradeWinds", "config.yml"); + assertTrue(check.exists()); + assertTrue(addon.getPlayerCommand().isPresent()); + assertTrue(addon.getAdminCommand().isPresent()); + assertNotNull(addon.getBiomeProvider()); + } + + /** + * Test method for {@link TradeWinds#onEnable()}. + */ + @Test + void testOnEnable() { + testOnLoad(); + addon.setState(State.ENABLED); + addon.onEnable(); + assertNotEquals(State.DISABLED, addon.getState()); + } + + /** + * Test method for {@link TradeWinds#onReload()}. + */ + @Test + void testOnReload() { + addon.onLoad(); + addon.onEnable(); + addon.onReload(); + File check = new File("addons/TradeWinds", "config.yml"); + assertTrue(check.exists()); + } + + /** + * Test method for {@link TradeWinds#createWorlds()}. The ocean overworld and + * the interstice are both created; the End never is. + */ + @Test + void testCreateWorlds() { + addon.onLoad(); + addon.createWorlds(); + verify(plugin).log("[tradewinds] Creating TradeWinds ocean..."); + verify(plugin).log("[tradewinds] Creating TradeWinds interstice..."); + assertNotNull(addon.getOverWorld()); + assertNotNull(addon.getNetherWorld()); + // No End world, ever (spec principle 7) + assertNull(addon.getEndWorld()); + } + + /** + * Interstice generation can be turned off; then only the ocean is created. + */ + @Test + void testCreateWorldsNoInterstice() { + addon.onLoad(); + addon.getSettings().setNetherGenerate(false); + addon.createWorlds(); + verify(plugin).log("[tradewinds] Creating TradeWinds ocean..."); + verify(plugin, never()).log("[tradewinds] Creating TradeWinds interstice..."); + assertNull(addon.getNetherWorld()); + } + + /** + * Test method for {@link TradeWinds#getSettings()}. + */ + @Test + void testGetSettings() { + addon.onLoad(); + assertNotNull(addon.getSettings()); + assertEquals(addon.getSettings(), addon.getWorldSettings()); + } + + /** + * The world generator is provided for both worlds. + */ + @Test + void testGetDefaultWorldGenerator() { + addon.onLoad(); + assertNotNull(addon.getDefaultWorldGenerator("tradewinds_world", "")); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/WhiteBox.java b/src/test/java/world/bentobox/tradewinds/WhiteBox.java new file mode 100644 index 0000000..2818ea9 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/WhiteBox.java @@ -0,0 +1,26 @@ +package world.bentobox.tradewinds; + +public class WhiteBox { + /** + * Sets the value of a private static field using Java Reflection. + * @param targetClass The class containing the static field. + * @param fieldName The name of the private static field. + * @param value The value to set the field to. + */ + public static void setInternalState(Class targetClass, String fieldName, Object value) { + try { + // 1. Get the Field object from the class + java.lang.reflect.Field field = targetClass.getDeclaredField(fieldName); + + // 2. Make the field accessible (required for private fields) + field.setAccessible(true); + + // 3. Set the new value. The first argument is 'null' for static fields. + field.set(null, value); + + } catch (NoSuchFieldException | IllegalAccessException e) { + // Wrap reflection exceptions in a runtime exception for clarity + throw new RuntimeException("Failed to set static field '" + fieldName + "' on class " + targetClass.getName(), e); + } + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java new file mode 100644 index 0000000..7f5d7b8 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -0,0 +1,208 @@ +package world.bentobox.tradewinds.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +import org.bukkit.Material; +import org.bukkit.World.Environment; +import org.bukkit.generator.ChunkGenerator.ChunkData; +import org.bukkit.generator.WorldInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; + +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.Settings; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Tests the ocean generation of {@link ChunkGeneratorWorld}: determinism, sea + * levels, palettes, and the Stage 1 terrain-scale hook. + * + * @author tastybento + */ +class ChunkGeneratorWorldTest extends CommonTestSetup { + + private static final long SEED = 12345L; + + private TradeWinds addon; + private Settings settings; + + /** + * Simple in-memory ChunkData recording every block set. + */ + private static class RecordingChunkData { + final Map blocks = new HashMap<>(); + + static long key(int x, int y, int z) { + return ((long) y << 16) | ((long) x << 8) | z; + } + + Material get(int x, int y, int z) { + return blocks.getOrDefault(key(x, y, z), Material.AIR); + } + } + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + } + + private WorldInfo worldInfo(Environment env, long seed) { + WorldInfo wi = mock(WorldInfo.class); + when(wi.getEnvironment()).thenReturn(env); + when(wi.getSeed()).thenReturn(seed); + when(wi.getMinHeight()).thenReturn(-64); + when(wi.getMaxHeight()).thenReturn(320); + return wi; + } + + private ChunkData chunkData(RecordingChunkData recorder) { + ChunkData cd = mock(ChunkData.class); + // setBlock(x, y, z, Material) + org.mockito.Mockito.doAnswer((Answer) inv -> { + recorder.blocks.put(RecordingChunkData.key(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2)), + inv.getArgument(3)); + return null; + }).when(cd).setBlock(org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(Material.class)); + // setRegion(x1, y1, z1, x2, y2, z2, Material) + org.mockito.Mockito.doAnswer((Answer) inv -> { + int x1 = inv.getArgument(0); + int y1 = inv.getArgument(1); + int z1 = inv.getArgument(2); + int x2 = inv.getArgument(3); + int y2 = inv.getArgument(4); + int z2 = inv.getArgument(5); + Material m = inv.getArgument(6); + for (int x = x1; x < x2; x++) { + for (int y = y1; y < y2; y++) { + for (int z = z1; z < z2; z++) { + recorder.blocks.put(RecordingChunkData.key(x, y, z), m); + } + } + } + return null; + }).when(cd).setRegion(org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any(Material.class)); + return cd; + } + + private RecordingChunkData generate(ChunkGeneratorWorld gen, Environment env, long seed, int cx, int cz) { + RecordingChunkData recorder = new RecordingChunkData(); + gen.generateNoise(worldInfo(env, seed), new Random(seed), cx, cz, chunkData(recorder)); + return recorder; + } + + @Test + void testOceanShape() { + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 0, 0); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + // Bedrock at the bottom + assertEquals(Material.BEDROCK, r.get(x, -64, z)); + // Solid stone base below the sea floor + assertEquals(Material.STONE, r.get(x, 0, z)); + // Water at sea level + assertEquals(Material.WATER, r.get(x, settings.getSeaHeight(), z)); + // Air above sea level + assertEquals(Material.AIR, r.get(x, settings.getSeaHeight() + 1, z)); + // Floor surface starts at or above the sea floor + Material atFloor = r.get(x, settings.getSeaFloor(), z); + assertTrue(atFloor == Material.SAND || atFloor == Material.SANDSTONE || atFloor == Material.WATER, + "Unexpected material at sea floor: " + atFloor); + } + } + } + + @Test + void testIntersticeShape() { + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + RecordingChunkData r = generate(gen, Environment.NETHER, SEED, 0, 0); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertEquals(Material.BEDROCK, r.get(x, -64, z)); + // Netherrack base below the sea floor + assertEquals(Material.NETHERRACK, r.get(x, 0, z)); + // Water sea at interstice sea level + assertEquals(Material.WATER, r.get(x, settings.getIntersticeSeaHeight(), z)); + assertEquals(Material.AIR, r.get(x, settings.getIntersticeSeaHeight() + 1, z)); + // Floor palette is nether-flavored + Material atFloor = r.get(x, settings.getIntersticeSeaFloor(), z); + assertTrue(atFloor == Material.SOUL_SAND || atFloor == Material.BASALT || atFloor == Material.WATER, + "Unexpected material at interstice floor: " + atFloor); + } + } + } + + @Test + void testDeterminism() { + // Same seed, same chunk -> identical blocks, across generator instances + RecordingChunkData a = generate(new ChunkGeneratorWorld(addon), Environment.NORMAL, SEED, 3, -7); + RecordingChunkData b = generate(new ChunkGeneratorWorld(addon), Environment.NORMAL, SEED, 3, -7); + assertEquals(a.blocks, b.blocks); + // Different seed -> different floor + RecordingChunkData c = generate(new ChunkGeneratorWorld(addon), Environment.NORMAL, SEED + 1, 3, -7); + assertFalse(a.blocks.equals(c.blocks)); + } + + @Test + void testNoLandAboveSeaLevelAtScaleOne() { + // Stage 0: flat ocean everywhere - nothing may poke above the sea surface + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 5, 5); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = settings.getSeaHeight() + 1; y < 320; y++) { + assertEquals(Material.AIR, r.get(x, y, z)); + } + } + } + } + + @Test + void testTerrainScaleHookLiftsLand() { + // Stage 1 readiness: a terrain scale > 1 must lift the floor above sea level + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon) { + @Override + protected double terrainScale(WorldInfo worldInfo, int worldX, int worldZ) { + return 3.0; + } + }; + RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 0, 0); + boolean landAboveSea = false; + for (int x = 0; x < 16 && !landAboveSea; x++) { + for (int z = 0; z < 16 && !landAboveSea; z++) { + Material m = r.get(x, settings.getSeaHeight() + 1, z); + landAboveSea = m == Material.SAND || m == Material.SANDSTONE; + } + } + assertTrue(landAboveSea, "terrainScale > 1 should lift terrain above sea level"); + } + + @Test + void testShouldGenerateFlags() { + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + // Vanilla terrain off: land only ever comes from island masks + assertFalse(gen.shouldGenerateNoise()); + assertFalse(gen.shouldGenerateSurface()); + assertTrue(gen.shouldGenerateMobs()); + assertFalse(gen.shouldGenerateCaves()); + assertTrue(gen.shouldGenerateDecorations()); + assertFalse(gen.shouldGenerateStructures()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java new file mode 100644 index 0000000..2583579 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java @@ -0,0 +1,65 @@ +package world.bentobox.tradewinds.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.World.Environment; +import org.bukkit.block.Biome; +import org.bukkit.generator.WorldInfo; +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.TradeWinds; + +/** + * Tests {@link TradeWindsBiomeProvider}. + * + * @author tastybento + */ +class TradeWindsBiomeProviderTest extends CommonTestSetup { + + private TradeWinds addon; + private Settings settings; + private TradeWindsBiomeProvider provider; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + provider = new TradeWindsBiomeProvider(addon); + } + + private WorldInfo worldInfo(Environment env) { + WorldInfo wi = mock(WorldInfo.class); + when(wi.getEnvironment()).thenReturn(env); + return wi; + } + + @Test + void testOceanBiomes() { + WorldInfo wi = worldInfo(Environment.NORMAL); + assertEquals(Biome.OCEAN, provider.getBiome(wi, 0, settings.getSeaHeight(), 0)); + assertEquals(Biome.OCEAN, provider.getBiome(wi, 0, 10, 0)); + assertEquals(settings.getDefaultAirBiome(), provider.getBiome(wi, 0, settings.getSeaHeight() + 1, 0)); + } + + @Test + void testIntersticeBiome() { + WorldInfo wi = worldInfo(Environment.NETHER); + assertEquals(Biome.NETHER_WASTES, provider.getBiome(wi, 0, 64, 0)); + assertEquals(Biome.NETHER_WASTES, provider.getBiome(wi, 0, 100, 0)); + } + + @Test + void testGetBiomes() { + assertTrue(provider.getBiomes(worldInfo(Environment.NORMAL)).contains(Biome.OCEAN)); + assertEquals(java.util.List.of(Biome.NETHER_WASTES), provider.getBiomes(worldInfo(Environment.NETHER))); + } +} diff --git a/tradewinds-design-decisions.md b/tradewinds-design-decisions.md new file mode 100644 index 0000000..aeb6bd0 --- /dev/null +++ b/tradewinds-design-decisions.md @@ -0,0 +1,81 @@ +# TradeWinds — Design Decisions & Open Questions +*Companion to `tradewinds-overview.md` (admin pitch) and `tradewinds-dev-plan.md` (stages). This captures what was decided, **why**, and what remains open. Intended as the seed for `TRADEWINDS_SPEC.md`.* + +--- + +## Design principles (load-bearing insights) + +These are the reasons the design works. If a future change breaks one of these, stop and reconsider. + +1. **The fuel/cargo tradeoff is the central mechanic.** Everything competes for hold space. This only binds because **trading transacts exclusively against bundles and boat hold** — never player inventory, ender chests, or carried shulkers. Any feature that lets sellable goods bypass the hold breaks the game's spine. +2. **Risk symmetry between travel modes.** Warping costs fuel but is instant; its risk is interstice interdiction. Rowing is free but slow; its risk is the lawless open ocean (no protection, no police, PvP live outside island ranges). Neither mode should become strictly dominant. +3. **Scans, not prices, balance contraband.** Sugar has infinite trivial supply (sugarcane farms), so its price cannot be the control. The customs-scan risk is the cost; that is why sugar can stay lucrative and smuggling can be a skill-expressing playstyle rather than a tax. +4. **Crime pays you into danger.** Contraband sells only at less-safe islands; Fugitives are barred from safe-island markets entirely. The richest criminals are structurally pushed into PvP space where bounty hunters can lawfully take them. This one rule closes the entire crime/bounty loop. +5. **The seed is the world.** Island positions, existence, types, biomes, names, and route-graph edge costs must ALL derive deterministically from the single shareable seed — pure functions of (seed, position), unit-testable with no Bukkit dependency. If any of these drifts to runtime randomness, seeds stop being shareable. +6. **Generator-driven terrain, not pasted terrain.** Island landmass comes from the chunk generator (Poseidon-style noise × radial mask), so lazy generation is just chunk generation: no pop-in, no paste pacing. Blueprints/structures decorate; they do not create the land. +7. **Scarcity by dimension removal.** No End world → shulker shells are unobtainable → cargo expanders are a purchasable, cap-controlled money sink. Do not add End access. + +## Decided — by system + +### World & generation +- Islands at fully **random ocean positions** (not grid-spaced), sparse by design, seeded; rejection-sampled against a minimum separation so protection zones never overlap (spatial-hash grid lookup at placement time). Blank ocean between islands; some space reserved for purchased player islands. +- **Density floor near spawn** guarantees a starter cluster regardless of seed luck. +- Whole-island biomes set per column at generation; vanilla decoration provides theming. Frozen-ocean ice is a **feature**: boats run fast on ice, so icy approaches are high-speed lanes (loved by rowers and pirates alike). +- Base generators: AcidIsland-style ocean/ocean-floor (no acid) for both the overworld and the dedicated Nether-environment interstice world. No End. +- Unowned BentoBox islands with protection flags per **security band** (EVE model: safe → less safe → anarchic); no block place/break normally; island name (Elite-style procedural) announced on entry. +- Reference dimensions from AcidIsland framing: ~1000 range, ~400 protection radius, border shown at protection edge. Islands never within sight of each other. + +### Travel +- Border is **passable**; rowing between/past islands is free. Reaching the border in a boat offers the warp dialog (Paper 1.21.6 dialog API); rowing through does nothing special. +- **Route graph** with seeded per-edge costs; default = Euclidean distance × multiplier; per-edge overrides allow cheap lanes and expensive frontiers without regenerating the world ("warp lanes" is the in-fiction excuse for cost/distance mismatch). +- Fuel value table (wood < coal < lava bucket); lava's non-stackability is deliberate tension (high energy, full slot). Fuel consumed from hold only. +- Warp: dismount → teleport → re-seat (proven AcidIsland `/ai` pattern); arrival just inside the destination border **on the bearing of the origin island**; nausea + blindness + configurable minor damage (lore: warping hurts; gates the under-equipped); portal particles + sound (no real portal blocks needed). +- **Charting:** warp dialog lists charted islands only. Chart by physically entering an island's range (free, slow) or buying chart data (fast, money sink — post-MVP good). Starter cluster pre-charted. Charting doubles as the discovery layer over lazy generation. + +### Interstice (warp failure) +- Configurable failure chance; on failure the player + boat land on interstice water **partway along the route**, with 1–3 Ghasts spawned. +- **Re-engaging to the original destination is always free** — the fuel was spent at engagement; failure is a detour, never a fuel loss. This rule is what makes stranding impossible; do not remove it. +- Ghast-vs-boat combat is intentionally survivable (fireballs can be batted back). + +### Cargo & progression +- Start kit: boat + 1 trading bundle (a bundle = one stack's worth of capacity). Up to 3 bundles → chest boat → lore-renamed shulker **cargo expanders**, purchased only, price ~doubling per unit, configurable cap. Long expansion runway is intentional: maxed boats carry fortunes and are worth defending or attacking. +- Boat owner recorded in entity PDC from day one (theft, persistence, chunk-unload questions all hang off this). + +### Economy +- Everything in Minecraft is tradeable. **BlueBook** (`~/git/bluebook`) supplies base prices; modifiers from island type, biome, security band, and per-island stock/price drift persisted via the BentoBox database. Players may sell found/crafted items through the same market. +- Margins scale with danger: the best prices live in the risky bands. + +### Contraband & customs +- Master **config gate disables all illegal-goods mechanics** for family servers; on by default. +- Contraband: **sugar** (never referred to as drugs, in code, config, or locale). Villager "passengers" in boats sellable at less-safe islands only (how the player boats a villager is their problem — emergent by design). +- **Scan on every entry** to island space — rowing or warping in alike. Chance scales with security band (high-sec scans often; anarchic never). Per-island scan cooldown prevents re-entry scumming. +- Detection starts a **chase, not an instant fine**: "customs patrol dispatched," police launch from the island, and the ~400 blocks of water are the smuggler's decision window — flee across the border, fight, or **jettison** the contraband (dumped items float, lootable by anyone: emergent piracy). Caught = police land a hit or close to proximity while contraband is aboard → confiscation + fine + rep loss. +- Fleeing a detection **flags you at that island** for a cooldown: re-entry skips the roll and launches police immediately. + +### Reputation, police, bounties +- Single global score, named bands (working proposal: Upstanding / Clean / Offender / Wanted / Fugitive), all thresholds and penalties configurable. Villager gossip API hooked for organic price penalties on the offended island. +- Recovery, three speeds: slow decay toward zero during clean play (crimes shadow you for sessions, not minutes); paying fines (capped — money buys you out of *Wanted*, not into virtue); positive acts (contracts/donations, post-MVP) above zero. Positive rep must buy something (better prices, lower scan odds) so *Upstanding* is a pursued status. +- **Wanted**: PvP damage override anywhere, growing bounty, police response at civilized islands. **Fugitive**: shoot-on-sight and barred from safe-island trading (see principle 4). Killing a wanted player pays the bounty with no rep penalty — bounty hunting is lawful work. +- **Bounty nameplate** (config option): a wanted player's bounty is displayed with their name over their head — a target for hunters, a badge of infamy pirates will cultivate. Implementation: per-player scoreboard team suffix, only applied when bounty > 0 (clean players show nothing); PlaceholderAPI placeholder exposed so nametag plugins (TAB etc.) can render it themselves instead of conflicting. Note: sneaking hides nameplates on foot, but players in boats cannot sneak — traveling pirates are always visible. +- Police mobs by mobility, not just element: **Phantoms pursue** (fast; combustion cancelled or day patrols burn at dawn), **Guardians hold the sea perimeter** (slow swimmers who will never catch a boat — they are area denial around the island, not chasers), **Golems** handle anyone who lands. All PDC-tagged, ticking `setTarget` reassertion, **zero drops** (or wanted players become a crime-powered iron farm). Pursue within the protection zone, break off when the target crosses the visible border (escape you can see), despawn beyond island range or on unload. +- Per-band knobs: scan chance **and** response size — a mid-band island may scan rarely but respond hard. Route planning with contraband should be map-reading skill. +- Anti-bait guard for innocent-kill penalties: direct kills only; prior damage from the victim exempts. Log edge cases, tune later; do not over-engineer pre-MVP. + +## Proposed but not yet adopted (needs Ben's sign-off) +- Concrete reputation numbers: −1000..+1000 scale; hit villager −5, kill villager −25, kill police −15, kill innocent −100, scan-caught −30 + fine, slave sale −20; decay ~+1 per 15 min clean active play; band thresholds +250 / 0 / −200 / −500. +- **Regional density = civilization**: occupied-position density higher near spawn, thinning outward, with security band correlated to local density (clusters civilized, isolated islands anarchic). Makes the map itself communicate risk. +- Wanted players suffer a higher warp-failure chance (crime feeds the interstice ambush meta). +- Interstice is a **shared** world (enabling warp-space interdiction/ambush — very Elite) rather than instanced pockets. +- Post-MVP: stall rental on NPC islands for the player market (goods join the island's trade pool; rent as sink; stall location matters via the route graph); insurance (hull/expanders insurable, cargo never); "last seen" wanted-player intel sold at trade posts; Elder Guardian as the top-tier wanted response (its fatigue aura hits innocents — `EntityPotionEffectEvent` cancellation is possible but fights the mob's design, hence demoted from standard police). + +## Open questions +1. **Name — RESOLVED: TradeWinds** (one word, house style like BSkyBlock/AcidIsland; repo `TradeWinds` under BentoBoxWorld). "Trade Wars MC" rejected — the TradeWars 2002 mark is still live, and "Wars" mis-signals a trading game; Sandlot's Tradewinds series is defunct and the phrase is generic. +2. **Poseidon internals:** can a radial positional mask multiply into its existing density tweaks, or is it cleaner to lift its sea-level/decoration handling into a fresh generator with the mask native? (First real Stage 1 task.) +3. **Dock placement:** deterministic terraformed shelf at a fixed bearing (predictable, teachable) vs. jigsaw terrain-adaptive placement (organic) — current lean: deterministic dock, adaptive decorations. +4. **Border interaction:** "click the wall" needs packet-level handling of client-side blocks; proximity trigger + action-bar prompt is cheaper and reads the same. Pick one; Border must run in passable/visual mode regardless. +5. **Boat loss rules:** what drops on boat destruction / player death? Full expander drop makes trader-piracy the apex game (very EVE) but is brutal; insurance is the counterweight. Undecided. +6. **Interstice PvP rules** if shared: can hunters lawfully take non-wanted players there, or is interdiction itself a rep crime? +7. **Scan/flag tuning:** scan cooldown length, flee-flag duration, "caught" proximity radius. +8. **Galaxies architecture:** multiple configured addon instances (AcidIsland/BSkyBlock coexistence model) vs. a world manager inside one addon. Deferred, but keep the world reference abstracted from day one. +9. **Villager shoreline safety:** golems can't path onto water; keep villagers inland behind the docks so drive-by boat raids don't trivialize the police, or add a ranged deterrent. +10. **Reputation numeric tuning** (see proposal above) and whether reputation is global or per-galaxy once galaxies exist. diff --git a/tradewinds-dev-plan.md b/tradewinds-dev-plan.md new file mode 100644 index 0000000..3cc7876 --- /dev/null +++ b/tradewinds-dev-plan.md @@ -0,0 +1,100 @@ +# TradeWinds — Development Plan +### Staged implementation for Claude Code + +Each stage produces a testable, playable increment. Stages are sized so a stage is one or a few focused Claude Code sessions, with clear acceptance criteria Claude Code can verify (unit tests where deterministic, manual test checklist where not). Reference code lives in `~/git`: BentoBox core, AcidIsland, Poseidon, Boxed, Border, BlueBook. + +**Before Stage 0:** write `TRADEWINDS_SPEC.md` in the repo root (same approach as GUSHER_SPEC.md) capturing the full design, plus a `CLAUDE.md` pointing at the reference repos, build commands, and coding conventions. Every stage below assumes Claude Code reads the spec first. + +--- + +## Stage 0 — Addon scaffold +**Goal:** an installable gamemode addon with an empty ocean world. + +- Maven project extending `GameModeAddon`; config, locale files, `/tw` player and admin commands. +- Register the overworld plus a dedicated Nether-environment world (interstice) — both using an AcidIsland-style generator (ocean over ocean floor, no acid), Nether variant styled accordingly. +- No End world. Config skeleton for all later knobs. + +**Accept:** addon loads alongside other gamemodes; `/tw` teleports to spawn in open ocean; interstice world exists and is inaccessible to players. + +## Stage 1 — Seeded galaxy: placement, biomes, names +**Goal:** deterministic island layout from one shareable seed. + +- Placement engine: seed → random sparse island positions (rejection sampling against minimum separation via the spatial-hash island grid), each with type (agricultural, industrial, …), security band, biome, and range/protection radius. +- Elite-BBC-style procedural name generator (token-pair digraphs), seeded. +- Chunk generator derived from Poseidon: terrain noise × radial falloff mask at island centers; pure ocean elsewhere; per-column biome from island type. Guaranteed starter cluster density near spawn. +- On first generation of an island's center chunk: register an unowned BentoBox island with flags per security band; announce name on entry. + +**Accept:** unit tests prove same seed → identical positions/types/names across runs; rowing out reveals distinct biomed islands with no visible pop-in; islands persist in the database. + +## Stage 2 — Island content +**Goal:** islands worth visiting. + +- Deterministic dock/market shelf terraformed at a fixed bearing from center; blueprint sets per type and biome pasted there. +- Jigsaw/structure-command placement for decorative structures (Boxed pattern). +- Villager population with professions per island type; resident iron golems. + +**Accept:** every generated island has a dock, market, villagers, and golems appropriate to its type and biome. + +## Stage 3 — Travel: warp, fuel, charting +**Goal:** the core movement loop. + +- Border addon integration in passable mode; proximity trigger at the border while boated opens the warp dialog (Paper 1.21.6 dialog API): charted destinations, fuel cost each, unreachable grayed out. +- Route graph: seeded edge costs, default Euclidean × multiplier, per-edge override config. +- Fuel item value table (wood, coal, lava bucket, …); consumption from trading bundle / boat hold only. +- Warp execution: dismount, cross-position teleport, re-seat in boat (AcidIsland `/ai` pattern); arrival just inside the destination border on the origin bearing; nausea + blindness + configurable minor damage; portal particles and sound. +- Charting: islands enter a player's chart by rowing into their range or (later) buying chart data; dialog lists charted islands only. Starter cluster pre-charted. + +**Accept:** full row-out/warp-back loop works including cross-distance re-seating; fuel deducts correctly; uncharted islands never appear in the dialog; costs match the seeded graph. + +## Stage 4 — Economy and cargo +**Goal:** buying low and selling high — the game becomes a game here. + +- BlueBook integration for base prices; per-island modifiers from type, biome, security band; per-island stock and price drift persisted via BentoBox database. +- Trade GUI at the dock transacting **only** against trading bundles and boat hold. +- Cargo progression: start kit (boat + 1 bundle); up to 3 bundles; chest boat; purchasable lore-renamed shulker "cargo expanders" up to a configurable cap, price doubling per unit. Boat owner tag in PDC. +- Vault economy hookup; starter balance config. + +**Accept:** profitable routes exist and differ by island type; items outside bundles/hold cannot be sold; expansion pricing and caps enforced; market state survives restarts. + +## Stage 5 — The interstice (warp failure) +**Goal:** risk on the fast route. + +- Configurable warp failure chance; on failure, place player + boat on interstice water partway along the route; spawn 1–3 Ghasts nearby. +- Re-engaging warp from the interstice to the original destination is always free (fuel was already spent). +- Interstice mob/PvP rules; cleanup of stale interstice entities. + +**Accept:** failure rate matches config; stranding is impossible (re-engage always available); ghast fight is survivable in a boat. + +## Stage 6 — Crime: reputation, customs, police +**Goal:** the law layer. Largest stage — split into three sessions. + +**6a — Reputation core:** global score with named bands and configurable thresholds; listeners for villager harm, golem kills, player kills (wanted vs innocent), contraband sales; slow clean-play decay; fine payment at civilized islands; villager gossip hook for price penalties. + +**6b — Customs and contraband:** contraband list (default: sugar; villager "passengers" flagged at less-safe islands only) behind a master config gate for family servers. Scan roll on any entry to island space (row or warp), chance per security band; detection message; caught = police reach proximity/land a hit while contraband aboard → confiscation, fine, rep loss; jettisoned cargo clears you; fleeing a scan flags you at that island for a cooldown. + +**6c — Police and bounties:** police spawner per security band — Golems (land), Guardians (sea perimeter), Phantoms (pursuit, combustion cancelled); PDC-tagged, zero drops, ticking `setTarget` task; pursue within protection zone, break off at the border, despawn beyond island range or on island unload. Wanted players: PvP damage override anywhere, bounty ledger growing per crime, paid to the killer with no rep penalty; Fugitive band barred from safe-island trading. Bounty **nameplate display** (config-gated): bounty shown with the player's name via per-player scoreboard team suffix, absent at zero bounty; expose a PlaceholderAPI placeholder so servers running TAB/nametag plugins can integrate instead of conflicting. + +**Accept:** each rep transition triggers the right consequences; smuggler chase (scan → flee/fight/jettison) plays out correctly; police never drop loot and never leak entities; bounty pays exactly once. + +## Stage 7 — Player islands +**Goal:** ownership. Largely existing BentoBox machinery. + +- Purchase command: sufficient balance → choose island blueprint → placement at a valid random position respecting minimum separation from all islands; standard island/team management, protection, and home warps thereafter. + +**Accept:** purchased islands appear in open ocean, never overlapping any range; AcidIsland-equivalent island life works. + +## Stage 8 — Post-MVP backlog (seeded, unscheduled) +- Player market stalls rentable on NPC islands (goods enter that island's trade pool; rent as money sink). +- Chart data as a purchasable good; wanted-player "last seen" intel for bounty hunters. +- Boat/expander insurance (cargo never insurable); boat destruction and death-drop rules tuning. +- Delivery contracts/missions for positive reputation. +- Multiple galaxies as additional worlds; inter-galaxy warp as endgame. +- Elder Guardian boss response for the most-wanted tier. + +--- + +### Working notes for Claude Code sessions +- Keep everything downstream of the seed **pure and unit-testable**: placement, names, biomes, route costs should be plain functions of (seed, position) with no Bukkit dependencies, tested headlessly. +- Isolate Bukkit-facing code behind small interfaces so listeners and tasks stay thin. +- Every config value in `config.yml` from the stage it's introduced — no hardcoded gameplay numbers. +- Manual test checklist per stage lives in `TESTING.md`; Claude Code updates it as features land. diff --git a/tradewinds-overview.md b/tradewinds-overview.md new file mode 100644 index 0000000..72bc02f --- /dev/null +++ b/tradewinds-overview.md @@ -0,0 +1,33 @@ +# TradeWinds +### A BentoBox gamemode of sea trading, smuggling, and piracy + +**The pitch:** Your players are merchant sailors in an endless procedurally generated ocean dotted with trading islands. They start with nothing but a boat and a single trading bundle, and build a fortune by buying goods cheap on one island and selling them dear on another. How they get rich is up to them — honest hauling, smuggling contraband past customs patrols, hunting bounties, or outright piracy. Every choice has a price, and the game keeps score. + +--- + +## The world +The ocean is generated from a single shareable seed. Trading islands appear at random positions across the ocean — sparsely scattered, and always far enough apart that you can never see one island from another. Each has its own biome, procedurally generated name, and economic character: farm islands sell food cheap, industrial islands want raw materials, and so on. Islands generate lazily as players push outward, so the world grows with your community. Villagers run the markets; the islands themselves are protected — no building or breaking. + +Each island has a **security level**, from *safe* (no PvP, no hostile mobs, heavy policing) down to *anarchic* (anything goes). The best profit margins — and the black markets — are in dangerous space. + +## Getting around +Boats are everything. Players row freely between islands, or reach an island's border and **warp**: a fuel-powered jump straight to any charted island, paid for with wood, coal, or lava carried in the boat's hold. Warping is fast but fuel and cargo compete for the same space — the game's central tradeoff. Rowing is free but slow, and the open ocean between islands is lawless. + +Warps occasionally *fail*, dropping the player into a hostile Nether sea mid-route with Ghasts inbound. Fight or re-engage the warp — the detour is free, the fright isn't. + +Cargo grows in stages: bundles → chest boat → purchasable cargo expanders, with costs that scale steeply. A maxed-out trade ship carries a fortune and is worth defending — or attacking. + +## Law and disorder +Every player has a **reputation**. Attacking villagers, killing innocents, or trading contraband (sugar, by default) drives it down; clean living slowly restores it. Slip too far and you're *Wanted*: open to PvP everywhere, carrying a bounty any player can collect, and met at civilized islands by the police — Iron Golems on land, Guardians at sea, Phantoms in the air. A wanted player's bounty hangs right over their head for all to see (configurable) — a target sign for hunters, a badge of infamy for pirates. + +Entering an island's waters triggers a **customs scan**. Get flagged with contraband and the patrol launches — flee, fight, or dump the evidence overboard before they close in. Smuggling is a skill, not a tax. + +Players who earn enough can **buy their own island**, placed in the open ocean between trading islands, and eventually sell their own crafted goods into the economy. + +## For admins +- Runs on **BentoBox** alongside your other gamemodes; prices derive from the **BlueBook** economy addon. +- Nearly everything is a config knob: island spacing, fuel costs and warp-route pricing, scan chances, police response size, reputation thresholds, warp failure rate, cargo expansion caps. +- **Contraband and slave-trading mechanics can be disabled entirely** for family-friendly servers. +- One seed defines the whole galaxy — share it and another server gets the same world. + +*Inspired by the classic trading games TradeWars 2002 and Elite — rebuilt entirely in Minecraft terms.* From 3ba45a08ab0e50729bbedc07d3f56db79c3edda9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 08:59:48 -0700 Subject: [PATCH 002/112] =?UTF-8?q?Stage=201:=20seeded=20galaxy=20?= =?UTF-8?q?=E2=80=94=20placement,=20bands,=20biomes,=20names,=20lazy=20reg?= =?UTF-8?q?istration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pure galaxy package (no Bukkit): GalaxyEngine, Elite-style NameGenerator, SplitMix64 hashing; constructive min-separation, starter density floor, distance-based security bands, per-type biome tables with frozen ice lanes - Generator terrainLift mask lifts grassy islands from the ocean floor - BiomeProvider resolves galaxy biome keys; registrar creates unowned named BentoBox islands on center-chunk load with band flags and metadata - 41 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 16 +- docs/PROGRESS.md | 38 +++ .../world/bentobox/tradewinds/Settings.java | 26 ++ .../world/bentobox/tradewinds/TradeWinds.java | 36 +++ .../tradewinds/galaxy/GalaxyConfig.java | 40 +++ .../tradewinds/galaxy/GalaxyEngine.java | 243 ++++++++++++++++++ .../bentobox/tradewinds/galaxy/Hashing.java | 43 ++++ .../tradewinds/galaxy/IslandSpec.java | 29 +++ .../tradewinds/galaxy/IslandType.java | 51 ++++ .../tradewinds/galaxy/NameGenerator.java | 57 ++++ .../tradewinds/galaxy/SecurityBand.java | 33 +++ .../generator/ChunkGeneratorWorld.java | 44 +++- .../generator/GalaxyIslandRegistrar.java | 85 ++++++ .../generator/TradeWindsBiomeProvider.java | 70 ++++- src/main/resources/config.yml | 8 + .../bentobox/tradewinds/SettingsTest.java | 6 + .../tradewinds/galaxy/GalaxyEngineTest.java | 152 +++++++++++ .../tradewinds/galaxy/NameGeneratorTest.java | 43 ++++ .../generator/ChunkGeneratorWorldTest.java | 44 +++- .../generator/GalaxyIslandRegistrarTest.java | 120 +++++++++ .../TradeWindsBiomeProviderTest.java | 41 ++- 21 files changed, 1184 insertions(+), 41 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/Hashing.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/IslandSpec.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/IslandType.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/NameGenerator.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/SecurityBand.java create mode 100644 src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java create mode 100644 src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/galaxy/NameGeneratorTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java diff --git a/TESTING.md b/TESTING.md index 229628c..e790e7e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -21,4 +21,18 @@ what only a live server can prove. - [ ] Restart the server: worlds reload, no duplicate-world or generator errors, chunks unchanged (fly the same area). - [ ] Other gamemodes still work (create/visit an AcidIsland island). -## Stage 1 — Seeded galaxy *(placeholder — filled in when Stage 1 lands)* +## Stage 1 — Seeded galaxy + +Set `galaxy.seed` in `addons/TradeWinds/config.yml` to a known value (e.g. `20260729`) +and delete the `tradewinds_world*` folders for a clean generation, then: + +- [ ] Console logs `TradeWinds galaxy seed: ` on first world access. +- [ ] Fly (or `/twadmin tp` / creative-fly a boat) toward the nearest island — one of the 5 starter islands should be within ~4–8k blocks of spawn (console logs each registration: name, type, band, coords). +- [ ] Islands rise smoothly from the ocean: underwater shelf → beach → grassy interior; no cliffs of floating terrain, no chunk-border seams, no pop-in (land is generated, not pasted). +- [ ] Each island has a single whole-island biome matching its logged type (e.g. MINING → windswept hills; FROZEN → snowy, with **ice sheets in the surrounding water ring** — ride a boat over the ice: it should be fast). +- [ ] Entering an island's protection range announces its name ("Now entering "). +- [ ] `bbox` island info at an island (`/twadmin info` while standing there) shows an unowned island, range 1000, protection 400. +- [ ] Restart the server: the same islands are still registered (no duplicate-registration log lines), names unchanged. +- [ ] Regenerate the world from scratch with the same seed (stop server, delete world folders AND `database/` TradeWinds islands): identical island positions, names, types. +- [ ] Islands are never within sight of one another (min separation 2500). +- [ ] PvP setting: on a LAWLESS/ANARCHIC island the island PVP flag is on; on SAFE it is off. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c97f57f..93d5f04 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,44 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 1 — Seeded galaxy (2026-07-29) — CODE COMPLETE, awaiting in-game test + +**Done:** +- `world.bentobox.tradewinds.galaxy` package — pure Java, zero Bukkit imports, + fully headless-tested: `GalaxyEngine` (placement, types, security bands, + biome keys, names), `GalaxyConfig`, `IslandSpec`, `IslandType`, + `SecurityBand`, `NameGenerator` (classic Elite digraph table), `Hashing` + (SplitMix64 over (seed, cell, salt) — order-independent, no shared Random). +- **Placement is constructive, not rejection-sampled:** cells of side + 2×min-separation, jitter capped at min-separation/2 → any two islands are + ≥ min-separation apart by construction. Deterministic and O(1) per query. +- **Starter density floor:** the `starter-cluster-min-islands` cells nearest + spawn are force-occupied and SAFE regardless of density rolls. +- **Bands by distance from spawn** (`galaxy.band-radius` per step) with ±1 + seeded wobble — the map communicates risk radially. FROZEN islands freeze + their approach ring (terrain radius → 2×) into frozen_ocean ice lanes. +- Generator: `terrainLift` (additive, cosine mask, `galaxy.land-lift` at + center) replaces the Stage 0 multiplicative hook; island columns that clear + the sea get stone/dirt/grass soil so vanilla decoration plants them. +- `TradeWindsBiomeProvider` resolves galaxy biome keys via `Registry.BIOME` + with cache; declares every possible biome in `getBiomes()`. +- `GalaxyIslandRegistrar` (ChunkLoadEvent): registers unowned BentoBox islands + at center-chunk first load — `createIsland(loc, null)`, name set (BentoBox's + ENTER_EXIT_MESSAGES announces it), PVP flag per band, type/band in island + metadata. Guarded by an in-session cell set + `getIslandAt` DB check. +- `TradeWinds.getGalaxyEngine(worldSeed)`: lazy init; `galaxy.seed` 0 means + "use the world seed" (logged so admins can share it). +- Tests: 41 total green (19 new: engine determinism/order-independence/ + separation/starter-floor/bands/lift/biome-ring, name generator, registrar, + updated generator + biome provider tests). + +**Pitfalls:** +- `putMetaData` on a fresh Island silently no-ops until `setMetaData(new + HashMap<>())` initializes the map — the registrar does this explicitly. +- NameGenerator: the Elite digraph table's '.' skip tokens can roll a 1-2 + letter name; the fallback loops until ≥3 letters (a single fallback append + could itself be the 1-letter "A." pair). + ## Stage 0 — Addon scaffold (2026-07-29) — CODE COMPLETE, awaiting in-game test **Done:** diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 1c91464..b6dacae 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -77,6 +77,24 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.starter-cluster-min-islands", needsReset = true) private int starterClusterMinIslands = 5; + @ConfigComment("Chance (0.0-1.0) that a galaxy grid cell hosts a trading island.") + @ConfigComment("Cells are 2 x min-separation across, so 0.5 averages one island per ~2 cells.") + @ConfigEntry(path = "galaxy.density", needsReset = true) + private double galaxyDensity = 0.5; + + @ConfigComment("Radius in blocks of an island's terrain footprint (land plus underwater shelf).") + @ConfigEntry(path = "galaxy.island-terrain-radius", needsReset = true) + private int islandTerrainRadius = 160; + + @ConfigComment("Blocks of terrain lift at an island's center. With sea-floor 25 and sea-height 70,") + @ConfigComment("45 puts mean island centers just above the waves with hills to ~30 blocks.") + @ConfigEntry(path = "galaxy.land-lift", needsReset = true) + private int landLift = 45; + + @ConfigComment("Distance from spawn per security-band step (Safe -> Policed -> Frontier -> Lawless -> Anarchic).") + @ConfigEntry(path = "galaxy.band-radius", needsReset = true) + private int bandRadius = 5000; + /* TRAVEL */ @ConfigComment("Base warp fuel cost multiplier: fuel units per block of Euclidean route distance.") @ConfigComment("Per-edge overrides come later via the route-graph config.") @@ -1997,6 +2015,14 @@ public void setConcurrentIslands(int concurrentIslands) { public void setStarterClusterRadius(int starterClusterRadius) { this.starterClusterRadius = starterClusterRadius; } public int getStarterClusterMinIslands() { return starterClusterMinIslands; } public void setStarterClusterMinIslands(int starterClusterMinIslands) { this.starterClusterMinIslands = starterClusterMinIslands; } + public double getGalaxyDensity() { return galaxyDensity; } + public void setGalaxyDensity(double galaxyDensity) { this.galaxyDensity = galaxyDensity; } + public int getIslandTerrainRadius() { return islandTerrainRadius; } + public void setIslandTerrainRadius(int islandTerrainRadius) { this.islandTerrainRadius = islandTerrainRadius; } + public int getLandLift() { return landLift; } + public void setLandLift(int landLift) { this.landLift = landLift; } + public int getBandRadius() { return bandRadius; } + public void setBandRadius(int bandRadius) { this.bandRadius = bandRadius; } public double getFuelPerBlock() { return fuelPerBlock; } public void setFuelPerBlock(double fuelPerBlock) { this.fuelPerBlock = fuelPerBlock; } public double getWarpFailureChance() { return warpFailureChance; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 0b7e54d..a8ce12f 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -18,7 +18,10 @@ import world.bentobox.bentobox.api.configuration.Config; import world.bentobox.bentobox.api.configuration.WorldSettings; import world.bentobox.bentobox.lists.Flags; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.generator.ChunkGeneratorWorld; +import world.bentobox.tradewinds.generator.GalaxyIslandRegistrar; import world.bentobox.tradewinds.generator.TradeWindsBiomeProvider; /** @@ -39,6 +42,7 @@ public class TradeWinds extends GameModeAddon { private @Nullable ChunkGenerator chunkGenerator; private final Config configObject = new Config<>(this, Settings.class); private BiomeProvider biomeProvider; + private @Nullable GalaxyEngine galaxyEngine; /** * This addon uses the new chunk generation API for the sea bottom @@ -87,6 +91,10 @@ public void onEnable() { if (netherWorld != null) { Flags.BOAT.setDefaultSetting(netherWorld, true); } + // Island names are announced on entry ("Now entering [name]") + Flags.ENTER_EXIT_MESSAGES.setDefaultSetting(islandWorld, true); + // Register trading islands lazily as their center chunks first load + registerListener(new GalaxyIslandRegistrar(this)); } @Override @@ -178,4 +186,32 @@ public void allLoaded() { public BiomeProvider getBiomeProvider() { return this.biomeProvider; } + + /** + * The seeded galaxy for the overworld. Created on first use because the + * effective seed may be the world's own seed (config galaxy.seed = 0), which + * is only known once the world exists. + * + * @param worldSeed the overworld seed, used when the config seed is 0 + * @return the galaxy engine + */ + public GalaxyEngine getGalaxyEngine(long worldSeed) { + if (galaxyEngine == null) { + Settings s = getSettings(); + long seed = s.getGalaxySeed() != 0 ? s.getGalaxySeed() : worldSeed; + galaxyEngine = new GalaxyEngine(new GalaxyConfig(seed, s.getGalaxyMinSeparation(), + s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), + s.getStarterClusterMinIslands(), s.getBandRadius())); + log("TradeWinds galaxy seed: " + seed); + } + return galaxyEngine; + } + + /** + * @return the galaxy engine, or null if no world query has initialized it yet + */ + @Nullable + public GalaxyEngine getGalaxyEngine() { + return galaxyEngine; + } } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java new file mode 100644 index 0000000..c773c32 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -0,0 +1,40 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Immutable configuration for the {@link GalaxyEngine}. Built from the addon + * Settings by the caller so the galaxy package stays free of Bukkit and + * BentoBox imports (spec principle 5: everything downstream of the seed is a + * pure function, unit-testable headlessly). + * + * @param seed the galaxy seed - the whole world derives from this one number + * @param minSeparation minimum distance in blocks between island centers + * @param terrainRadius radius in blocks of an island's terrain (land + shelf) mask + * @param landLift blocks of terrain lift at an island's center; tapers to 0 at terrainRadius + * @param density chance (0-1) that a galaxy grid cell hosts an island + * @param starterMinIslands the guaranteed number of islands nearest spawn (density floor) + * @param bandRadius distance from spawn per security-band step, in blocks + * + * @author tastybento + */ +public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, + int starterMinIslands, int bandRadius) { + + /** + * Grid cell size in blocks. With jitter confined to +/- minSeparation/2 of a + * cell's nominal center, islands in any two cells are constructively + * guaranteed to be at least minSeparation apart - no runtime rejection loop + * needed, and the result is order-independent and deterministic. + * @return cell size in blocks + */ + public int cellSize() { + return minSeparation * 2; + } + + /** + * Maximum jitter of an island position from its cell's nominal center. + * @return jitter radius in blocks + */ + public int jitter() { + return minSeparation / 2; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java new file mode 100644 index 0000000..a194a0a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -0,0 +1,243 @@ +package world.bentobox.tradewinds.galaxy; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * The seeded galaxy: which cells host trading islands, where exactly, and what + * each island is (type, security band, biome, name). Every answer is a pure + * function of (seed, cell) - same seed, same galaxy, on any server (spec + * principle 5). No Bukkit imports; results are cached but cache state never + * affects outcomes. + *

+ * Placement scheme: the ocean is a grid of cells of side 2 x min-separation. + * Each cell rolls occupancy against the density, and an occupied cell places + * its island at the cell's nominal center plus a jitter of at most + * min-separation / 2 in each axis. Any two islands are therefore at least + * min-separation apart by construction - deterministic, order-independent, and + * no rejection loop. + *

+ * Density floor: the {@code starterMinIslands} cells nearest spawn are forced + * occupied (and SAFE) regardless of their density roll, so every seed has a + * playable starter cluster. + * + * @author tastybento + */ +public class GalaxyEngine { + + // Salts for the independent per-cell attribute rolls + private static final long SALT_OCCUPANCY = 0x0CCA9A9CL; + private static final long SALT_JITTER_X = 0x7177E12AL; + private static final long SALT_JITTER_Z = 0x7177E12BL; + private static final long SALT_TYPE = 0x7E9BE001L; + private static final long SALT_BAND = 0xBA9D0001L; + private static final long SALT_BIOME = 0xB10E0001L; + private static final long SALT_NAME = 0x9A3E0001L; + + private final GalaxyConfig config; + private final Set starterCells; + private final Map> cache = new ConcurrentHashMap<>(); + + public GalaxyEngine(GalaxyConfig config) { + this.config = config; + this.starterCells = computeStarterCells(); + } + + public GalaxyConfig getConfig() { + return config; + } + + private static long cellKey(int cellX, int cellZ) { + return ((long) cellX << 32) ^ (cellZ & 0xFFFFFFFFL); + } + + /** + * The starterMinIslands cells whose nominal centers are nearest spawn (0,0), + * deterministically ordered by (distance, cellX, cellZ). + */ + private Set computeStarterCells() { + int need = config.starterMinIslands(); + // Search enough rings to certainly contain the k nearest cells + int rings = 1; + while ((2 * rings) * (2L * rings) < need * 4L) { + rings++; + } + rings++; + record Candidate(int cx, int cz, double dist) { + } + List candidates = new ArrayList<>(); + for (int cx = -rings - 1; cx <= rings; cx++) { + for (int cz = -rings - 1; cz <= rings; cz++) { + double x = (cx + 0.5) * config.cellSize(); + double z = (cz + 0.5) * config.cellSize(); + candidates.add(new Candidate(cx, cz, Math.hypot(x, z))); + } + } + return candidates.stream() + .sorted(Comparator.comparingDouble(Candidate::dist).thenComparingInt(Candidate::cx) + .thenComparingInt(Candidate::cz)) + .limit(need).map(c -> cellKey(c.cx(), c.cz())).collect(Collectors.toSet()); + } + + /** + * The island hosted by a galaxy cell, if any. Pure function of (seed, cell). + * + * @param cellX cell x + * @param cellZ cell z + * @return the island spec, or empty if the cell is open ocean + */ + public Optional islandInCell(int cellX, int cellZ) { + return cache.computeIfAbsent(cellKey(cellX, cellZ), k -> computeIsland(cellX, cellZ)); + } + + private Optional computeIsland(int cellX, int cellZ) { + boolean starter = starterCells.contains(cellKey(cellX, cellZ)); + if (!starter + && Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_OCCUPANCY)) >= config.density()) { + return Optional.empty(); + } + int size = config.cellSize(); + int jitter = config.jitter(); + double jx = Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_JITTER_X)) * 2 - 1; + double jz = Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_JITTER_Z)) * 2 - 1; + int centerX = (int) Math.round((cellX + 0.5) * size + jx * jitter); + int centerZ = (int) Math.round((cellZ + 0.5) * size + jz * jitter); + + IslandType type = rollType(cellX, cellZ); + SecurityBand band = starter ? SecurityBand.SAFE : rollBand(cellX, cellZ, centerX, centerZ); + String biome = rollBiome(cellX, cellZ, type); + String name = NameGenerator.name(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_NAME)); + return Optional.of(new IslandSpec(cellX, cellZ, centerX, centerZ, type, band, biome, name)); + } + + private IslandType rollType(int cellX, int cellZ) { + int total = 0; + for (IslandType t : IslandType.values()) { + total += t.getWeight(); + } + long roll = Math.floorMod(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_TYPE), total); + for (IslandType t : IslandType.values()) { + roll -= t.getWeight(); + if (roll < 0) { + return t; + } + } + return IslandType.AGRICULTURAL; // Unreachable + } + + /** + * Security by distance from spawn - the map itself communicates risk: the + * further out, the more lawless, with a one-step seeded wobble so band + * borders are not perfect circles. + */ + private SecurityBand rollBand(int cellX, int cellZ, int centerX, int centerZ) { + double dist = Math.hypot(centerX, centerZ); + int step = (int) (dist / config.bandRadius()); + // Wobble -1, 0 or +1 + int wobble = (int) Math.floorMod(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_BAND), 3) - 1; + int index = Math.clamp(step + wobble, 0, SecurityBand.values().length - 1); + return SecurityBand.values()[index]; + } + + private String rollBiome(int cellX, int cellZ, IslandType type) { + List keys = type.getBiomeKeys(); + int i = (int) Math.floorMod(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_BIOME), keys.size()); + return keys.get(i); + } + + /** + * All islands whose centers lie within a square search radius of a block + * position. Scans just the overlapping cells - O(1) for radii up to a few + * cells. + * + * @param blockX block x + * @param blockZ block z + * @param radius search radius in blocks + * @return islands found, possibly empty + */ + public List islandsNear(int blockX, int blockZ, int radius) { + int size = config.cellSize(); + int minCellX = Math.floorDiv(blockX - radius, size); + int maxCellX = Math.floorDiv(blockX + radius, size); + int minCellZ = Math.floorDiv(blockZ - radius, size); + int maxCellZ = Math.floorDiv(blockZ + radius, size); + List result = new ArrayList<>(); + for (int cx = minCellX; cx <= maxCellX; cx++) { + for (int cz = minCellZ; cz <= maxCellZ; cz++) { + islandInCell(cx, cz).filter( + s -> Math.abs(s.centerX() - blockX) <= radius && Math.abs(s.centerZ() - blockZ) <= radius) + .ifPresent(result::add); + } + } + return result; + } + + /** + * The island whose terrain footprint covers a block position, if any. + * + * @param blockX block x + * @param blockZ block z + * @return the island spec, or empty in open ocean + */ + public Optional islandAt(int blockX, int blockZ) { + long r2 = (long) config.terrainRadius() * config.terrainRadius(); + return islandsNear(blockX, blockZ, config.terrainRadius()).stream() + .filter(s -> s.distanceSquared(blockX, blockZ) <= r2).findFirst(); + } + + /** + * Terrain lift in blocks at a column: {@code landLift} at an island center, + * cosine-tapered to 0 at the terrain radius, 0 in open ocean. This is what + * the chunk generator adds to the ocean floor height - it is the only + * source of land in the world. + * + * @param blockX block x + * @param blockZ block z + * @return lift in blocks, 0 in open ocean + */ + public int landLiftAt(int blockX, int blockZ) { + int radius = config.terrainRadius(); + double best = 0; + for (IslandSpec s : islandsNear(blockX, blockZ, radius)) { + double d = Math.sqrt(s.distanceSquared(blockX, blockZ)); + if (d < radius) { + double mask = 0.5 * (1 + Math.cos(Math.PI * d / radius)); + best = Math.max(best, mask); + } + } + return (int) Math.round(config.landLift() * best); + } + + /** + * The biome key governing a column, or empty for default ocean handling. + * Within an island's terrain radius this is the island biome; in the + * approach ring (out to 2x the terrain radius) of a FROZEN island it is + * frozen ocean, so ice forms into fast boat lanes. + * + * @param blockX block x + * @param blockZ block z + * @return biome key, or empty for open ocean + */ + public Optional biomeKeyAt(int blockX, int blockZ) { + int radius = config.terrainRadius(); + long r2 = (long) radius * radius; + long ring2 = 4L * radius * radius; + Optional ring = Optional.empty(); + for (IslandSpec s : islandsNear(blockX, blockZ, radius * 2)) { + long d2 = s.distanceSquared(blockX, blockZ); + if (d2 <= r2) { + return Optional.of(s.biomeKey()); + } + if (d2 <= ring2 && s.type().isIcyApproach()) { + ring = Optional.of("minecraft:frozen_ocean"); + } + } + return ring; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/Hashing.java b/src/main/java/world/bentobox/tradewinds/galaxy/Hashing.java new file mode 100644 index 0000000..82fd048 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/Hashing.java @@ -0,0 +1,43 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Deterministic hashing for the galaxy: SplitMix64 finalizer over (seed, cell, + * salt). Everything the galaxy decides flows through these functions - never + * {@code java.util.Random} state shared across queries, so results are + * order-independent. + * + * @author tastybento + */ +public final class Hashing { + + private Hashing() { + // Static use only + } + + /** + * SplitMix64 finalizer - the standard 64-bit avalanche mix. + */ + public static long mix(long z) { + z += 0x9E3779B97F4A7C15L; + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } + + /** + * Hash of (seed, cell coordinates, salt). + */ + public static long cellHash(long seed, int cellX, int cellZ, long salt) { + long h = mix(seed ^ salt); + h = mix(h ^ (cellX * 0x632BE59BD9B4E019L)); + h = mix(h ^ (cellZ * 0x9E3779B97F4A7C15L)); + return h; + } + + /** + * Uniform double in [0, 1) from a hash. + */ + public static double toUnit(long hash) { + return (hash >>> 11) * 0x1.0p-53; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/IslandSpec.java b/src/main/java/world/bentobox/tradewinds/galaxy/IslandSpec.java new file mode 100644 index 0000000..0984ef7 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/IslandSpec.java @@ -0,0 +1,29 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Everything the galaxy knows about one trading island, derived purely from + * (seed, cell). Two engines with the same seed produce identical specs. + * + * @param cellX galaxy grid cell x + * @param cellZ galaxy grid cell z + * @param centerX island center block x + * @param centerZ island center block z + * @param type economic character + * @param band security band + * @param biomeKey namespaced biome key for the whole island + * @param name procedural island name + * + * @author tastybento + */ +public record IslandSpec(int cellX, int cellZ, int centerX, int centerZ, IslandType type, SecurityBand band, + String biomeKey, String name) { + + /** + * Squared distance from a block position to this island's center. + */ + public long distanceSquared(int blockX, int blockZ) { + long dx = (long) blockX - centerX; + long dz = (long) blockZ - centerZ; + return dx * dx + dz * dz; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/IslandType.java b/src/main/java/world/bentobox/tradewinds/galaxy/IslandType.java new file mode 100644 index 0000000..47b3a29 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/IslandType.java @@ -0,0 +1,51 @@ +package world.bentobox.tradewinds.galaxy; + +import java.util.List; + +/** + * Economic character of a trading island. Drives the market modifiers (Stage 4), + * the blueprint decoration set (Stage 2), and the island's biome. Biomes are + * plain namespaced keys here - the galaxy package has no Bukkit imports; the + * generator resolves keys to Biome instances at the edge. + * + * @author tastybento + */ +public enum IslandType { + /** Farms: sells food cheap, wants tools and materials. */ + AGRICULTURAL(10, List.of("minecraft:plains", "minecraft:sunflower_plains", "minecraft:meadow")), + /** Lumber and living wood. */ + FOREST(10, List.of("minecraft:forest", "minecraft:birch_forest", "minecraft:dark_forest")), + /** Fisheries: sells fish and sea goods. */ + FISHING(10, List.of("minecraft:beach", "minecraft:stony_shore", "minecraft:mangrove_swamp")), + /** Ore and stone: sells minerals, wants food. */ + MINING(10, List.of("minecraft:windswept_hills", "minecraft:stony_peaks", "minecraft:savanna_plateau")), + /** Smelters and works: wants raw materials, sells finished goods. */ + INDUSTRIAL(8, List.of("minecraft:desert", "minecraft:badlands")), + /** Rich resorts: buys luxuries dear. */ + LUXURY(4, List.of("minecraft:cherry_grove", "minecraft:flower_forest")), + /** Frozen outposts: icy approaches are high-speed boat lanes. */ + FROZEN(8, List.of("minecraft:snowy_plains", "minecraft:snowy_taiga", "minecraft:ice_spikes")); + + private final int weight; + private final List biomeKeys; + + IslandType(int weight, List biomeKeys) { + this.weight = weight; + this.biomeKeys = biomeKeys; + } + + public int getWeight() { + return weight; + } + + public List getBiomeKeys() { + return biomeKeys; + } + + /** + * @return true if this type's surrounding waters freeze into fast ice lanes + */ + public boolean isIcyApproach() { + return this == FROZEN; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/NameGenerator.java b/src/main/java/world/bentobox/tradewinds/galaxy/NameGenerator.java new file mode 100644 index 0000000..6c565ad --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/NameGenerator.java @@ -0,0 +1,57 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Elite-BBC-style procedural island name generator: names are built from 2-4 + * two-letter tokens drawn from the classic digraph table, so they come out + * pronounceable and distinct ("Lave", "Zaonce", "Tibedied"...). Pure function + * of the input hash. + * + * @author tastybento + */ +public final class NameGenerator { + + /** + * The classic Elite token table: 32 digraphs, '.' means "skip this slot", + * which is what produces the variable-length names. + */ + private static final String[] PAIRS = { "..", "LE", "XE", "GE", "ZA", "CE", "BI", "SO", "US", "ES", "AR", "MA", + "IN", "DI", "RE", "A.", "ER", "AT", "EN", "BE", "RA", "LA", "VE", "TI", "ED", "OR", "QU", "AN", "TE", "IS", + "RI", "ON" }; + + private NameGenerator() { + // Static use only + } + + /** + * Generate a name from a hash. The same hash always yields the same name. + * + * @param hash seed for this name + * @return a capitalized, pronounceable island name (2-8 letters) + */ + public static String name(long hash) { + long h = hash; + StringBuilder sb = new StringBuilder(); + // 3 or 4 token slots + int tokens = 3 + (int) (Hashing.mix(h) & 1); + for (int i = 0; i < tokens; i++) { + h = Hashing.mix(h + i + 1); + String pair = PAIRS[(int) Math.floorMod(h, PAIRS.length)]; + for (char c : pair.toCharArray()) { + if (c != '.') { + sb.append(c); + } + } + } + while (sb.length() < 3) { + // Degenerate skip-heavy roll: keep appending until pronounceable + h = Hashing.mix(h); + for (char c : PAIRS[1 + (int) Math.floorMod(h, PAIRS.length - 1)].toCharArray()) { + if (c != '.') { + sb.append(c); + } + } + } + String s = sb.toString().toLowerCase(); + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/SecurityBand.java b/src/main/java/world/bentobox/tradewinds/galaxy/SecurityBand.java new file mode 100644 index 0000000..d83fd8f --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/SecurityBand.java @@ -0,0 +1,33 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Island security bands, EVE-style: safest nearest spawn, lawless out in the + * deep ocean. The band drives protection flags, customs scan chances, police + * response and market access as later stages land. + * + * @author tastybento + */ +public enum SecurityBand { + SAFE("Safe"), + POLICED("Policed"), + FRONTIER("Frontier"), + LAWLESS("Lawless"), + ANARCHIC("Anarchic"); + + private final String displayName; + + SecurityBand(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + /** + * @return true if PvP is live on this island's space + */ + public boolean isPvp() { + return this == LAWLESS || this == ANARCHIC; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index 0101262..b32ddb7 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -65,17 +65,21 @@ public ChunkGeneratorWorld(TradeWinds addon) { } /** - * Terrain amplitude multiplier at a world column. Stage 0: flat ocean everywhere, - * so always 1. Stage 1 multiplies in the radial island mask here: >1 near island - * centers lifts the floor above sea level; 1 is plain ocean floor. + * Terrain lift in blocks at a world column - the seeded galaxy's radial + * island mask. This is the only source of land in the world: 0 is plain + * ocean floor; near an island center the lift raises the floor above sea + * level. The interstice has no islands. * * @param worldInfo world being generated * @param worldX world x of the column * @param worldZ world z of the column - * @return amplitude multiplier, >= 0 + * @return lift in blocks, >= 0 */ - protected double terrainScale(WorldInfo worldInfo, int worldX, int worldZ) { - return 1.0; + protected int terrainLift(WorldInfo worldInfo, int worldX, int worldZ) { + if (worldInfo.getEnvironment() != Environment.NORMAL) { + return 0; + } + return addon.getGalaxyEngine(worldInfo.getSeed()).landLiftAt(worldX, worldZ); } @Override @@ -101,17 +105,18 @@ public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, if (wc.seaFloor() > minHeight + 1) { chunkData.setRegion(0, minHeight + 1, 0, 16, wc.seaFloor(), 16, mats.deepBase()); } - // Noised floor surface, then water up to sea level + // Noised floor surface (plus island lift), then water up to sea level for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { int worldX = (chunkX << 4) + x; int worldZ = (chunkZ << 4) + z; double noiseVal = gen.noise(worldX, worldZ, 0.5, 0.5, true); - double scale = terrainScale(worldInfo, worldX, worldZ); - int floorTop = wc.seaFloor() + (int) ((NOISE_MAX + NOISE_MAX * noiseVal) * scale); + int lift = terrainLift(worldInfo, worldX, worldZ); + int floorTop = wc.seaFloor() + (int) (NOISE_MAX + NOISE_MAX * noiseVal) + lift; floorTop = Math.min(floorTop, worldInfo.getMaxHeight() - 1); + boolean land = floorTop > wc.seaHeight() + 1; for (int y = wc.seaFloor(); y < floorTop; y++) { - chunkData.setBlock(x, y, z, rand.nextBoolean() ? mats.top() : mats.base()); + chunkData.setBlock(x, y, z, columnMaterial(mats, y, floorTop, land)); } // Water column above the floor for (int y = Math.max(floorTop, wc.seaFloor()); y <= wc.seaHeight(); y++) { @@ -121,6 +126,25 @@ public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, } } + /** + * Material for one block of a floor column. Underwater columns are the + * sea-floor palette; island columns that clear the sea get a soil profile + * (stone core, dirt subsoil, grass on top) so vanilla decoration can plant + * on them. + */ + private Material columnMaterial(FloorMats mats, int y, int floorTop, boolean land) { + if (!land) { + return rand.nextBoolean() ? mats.top() : mats.base(); + } + if (y == floorTop - 1) { + return Material.GRASS_BLOCK; + } + if (y >= floorTop - 4) { + return Material.DIRT; + } + return Material.STONE; + } + @Override public boolean shouldGenerateNoise() { // No vanilla terrain: land only ever comes from the island masks diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java new file mode 100644 index 0000000..46e1bd4 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -0,0 +1,85 @@ +package world.bentobox.tradewinds.generator; + +import java.util.HashMap; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Location; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.world.ChunkLoadEvent; + +import world.bentobox.bentobox.api.metadata.MetaDataValue; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.lists.Flags; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Registers trading islands as unowned BentoBox islands, lazily: when the chunk + * containing an island's center first loads, the island data object is created, + * named, flagged per its security band, and persisted by BentoBox. Terrain is + * NOT pasted - the chunk generator is the only source of land (spec principle + * 6); this listener only creates the protection/data layer on top. + * + * @author tastybento + */ +public class GalaxyIslandRegistrar implements Listener { + + public static final String META_TYPE = "tradewinds-type"; + public static final String META_BAND = "tradewinds-band"; + + private final TradeWinds addon; + // Cells already handled this session - cheap re-load guard + private final Set handled = ConcurrentHashMap.newKeySet(); + + public GalaxyIslandRegistrar(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkLoad(ChunkLoadEvent event) { + if (!event.getWorld().equals(addon.getOverWorld())) { + return; + } + int minX = event.getChunk().getX() << 4; + int minZ = event.getChunk().getZ() << 4; + // Islands whose center falls inside this chunk (searching the chunk's + // center with an 8-block box covers exactly the chunk) + addon.getGalaxyEngine(event.getWorld().getSeed()).islandsNear(minX + 8, minZ + 8, 8).stream() + .filter(s -> s.centerX() >= minX && s.centerX() < minX + 16 + && s.centerZ() >= minZ && s.centerZ() < minZ + 16) + .forEach(s -> register(s, event)); + } + + private void register(IslandSpec spec, ChunkLoadEvent event) { + long key = ((long) spec.cellX() << 32) ^ (spec.cellZ() & 0xFFFFFFFFL); + if (!handled.add(key)) { + return; + } + Location center = new Location(event.getWorld(), spec.centerX() + 0.5, + addon.getSettings().getSeaHeight() + 1.0, spec.centerZ() + 0.5); + if (addon.getIslands().getIslandAt(center).isPresent()) { + // Already registered in a previous session + return; + } + Island island = addon.getIslands().createIsland(center, null); + if (island == null) { + addon.logError("Could not register trading island " + spec.name() + " at " + spec.centerX() + "," + + spec.centerZ()); + handled.remove(key); + return; + } + island.setName(spec.name()); + // Security band flags: PvP live in lawless space + island.setSettingsFlag(Flags.PVP_OVERWORLD, spec.band().isPvp()); + if (island.getMetaData().isEmpty()) { + island.setMetaData(new HashMap<>()); + } + island.putMetaData(META_TYPE, new MetaDataValue(spec.type().name())); + island.putMetaData(META_BAND, new MetaDataValue(spec.band().name())); + addon.log("Registered trading island '" + spec.name() + "' (" + spec.type() + ", " + spec.band() + ") at " + + spec.centerX() + "," + spec.centerZ()); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java b/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java index c3d4415..a9f2920 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java +++ b/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java @@ -1,23 +1,34 @@ package world.bentobox.tradewinds.generator; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; import org.bukkit.block.Biome; import org.bukkit.generator.BiomeProvider; import org.bukkit.generator.WorldInfo; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandType; /** - * Biomes for the TradeWinds worlds. Stage 0: uniform ocean (sea biome at and - * below sea level, air biome above) and a single interstice biome. Stage 1 adds - * per-island whole-island biomes from the seeded galaxy. + * Biomes for the TradeWinds worlds. Open ocean is the sea biome at and below + * sea level and the air biome above (Poseidon pattern). Columns inside a + * trading island's terrain footprint take the island's whole-island biome from + * the seeded galaxy; FROZEN islands additionally freeze their approach ring + * into fast ice lanes. The interstice has a single biome. * * @author tastybento */ public class TradeWindsBiomeProvider extends BiomeProvider { private final TradeWinds addon; + // Biome key resolution cache; galaxy keys are a small fixed set + private final Map resolved = new ConcurrentHashMap<>(); public TradeWindsBiomeProvider(TradeWinds addon) { this.addon = addon; @@ -25,19 +36,56 @@ public TradeWindsBiomeProvider(TradeWinds addon) { @Override public Biome getBiome(WorldInfo worldInfo, int x, int y, int z) { - return switch (worldInfo.getEnvironment()) { - case NETHER -> addon.getSettings().getDefaultNetherBiome(); - default -> y <= addon.getSettings().getSeaHeight() + if (worldInfo.getEnvironment() == org.bukkit.World.Environment.NETHER) { + return addon.getSettings().getDefaultNetherBiome(); + } + Optional key = addon.getGalaxyEngine(worldInfo.getSeed()).biomeKeyAt(x, z); + if (key.isPresent()) { + return resolve(key.get()); + } + return y <= addon.getSettings().getSeaHeight() ? addon.getSettings().getDefaultBiome() : addon.getSettings().getDefaultAirBiome(); - }; + } + + /** + * Resolve a namespaced biome key to a Biome, falling back to the sea biome + * for unknown keys (logged once). + */ + private Biome resolve(String key) { + return resolved.computeIfAbsent(key, k -> { + NamespacedKey nk = NamespacedKey.fromString(k); + Biome biome = nk == null ? null : Registry.BIOME.get(nk); + if (biome == null) { + addon.logError("Unknown biome key in galaxy tables: " + k); + return addon.getSettings().getDefaultBiome(); + } + return biome; + }); } @Override public List getBiomes(WorldInfo worldInfo) { - return switch (worldInfo.getEnvironment()) { - case NETHER -> List.of(addon.getSettings().getDefaultNetherBiome()); - default -> List.of(addon.getSettings().getDefaultBiome(), addon.getSettings().getDefaultAirBiome()); - }; + if (worldInfo.getEnvironment() == org.bukkit.World.Environment.NETHER) { + return List.of(addon.getSettings().getDefaultNetherBiome()); + } + List biomes = new ArrayList<>(); + biomes.add(addon.getSettings().getDefaultBiome()); + if (!biomes.contains(addon.getSettings().getDefaultAirBiome())) { + biomes.add(addon.getSettings().getDefaultAirBiome()); + } + for (IslandType type : IslandType.values()) { + type.getBiomeKeys().forEach(k -> { + Biome b = resolve(k); + if (!biomes.contains(b)) { + biomes.add(b); + } + }); + } + Biome frozen = resolve("minecraft:frozen_ocean"); + if (!biomes.contains(frozen)) { + biomes.add(frozen); + } + return biomes; } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 50bf8b5..ce06e35 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -21,6 +21,14 @@ galaxy: starter-cluster-radius: 5000 # Minimum number of trading islands guaranteed inside the starter cluster radius. starter-cluster-min-islands: 5 + # Chance (0.0-1.0) that a galaxy grid cell hosts a trading island. + density: 0.5 + # Radius in blocks of an island's terrain footprint (land plus underwater shelf). + island-terrain-radius: 160 + # Blocks of terrain lift at an island's center. + land-lift: 45 + # Distance from spawn per security-band step (Safe -> Policed -> Frontier -> Lawless -> Anarchic). + band-radius: 5000 travel: warp: # Base warp fuel cost multiplier: fuel units per block of Euclidean route distance. diff --git a/src/test/java/world/bentobox/tradewinds/SettingsTest.java b/src/test/java/world/bentobox/tradewinds/SettingsTest.java index a170a86..4d7aa2a 100644 --- a/src/test/java/world/bentobox/tradewinds/SettingsTest.java +++ b/src/test/java/world/bentobox/tradewinds/SettingsTest.java @@ -73,6 +73,12 @@ void testGalaxyDefaults() { assertTrue(settings.getGalaxyMinSeparation() >= 2 * settings.getIslandDistance()); assertEquals(5000, settings.getStarterClusterRadius()); assertEquals(5, settings.getStarterClusterMinIslands()); + assertEquals(0.5, settings.getGalaxyDensity()); + assertEquals(160, settings.getIslandTerrainRadius()); + assertEquals(45, settings.getLandLift()); + assertEquals(5000, settings.getBandRadius()); + // Terrain must fit well inside the protection range + assertTrue(settings.getIslandTerrainRadius() < settings.getIslandProtectionRange()); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java new file mode 100644 index 0000000..90f319c --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -0,0 +1,152 @@ +package world.bentobox.tradewinds.galaxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +/** + * Headless tests of the seeded galaxy - no Bukkit anywhere (spec principle 5). + * + * @author tastybento + */ +class GalaxyEngineTest { + + private static final long SEED = 987654321L; + + private GalaxyConfig config(long seed, double density) { + return new GalaxyConfig(seed, 2500, 160, 45, density, 5, 5000); + } + + private List islands(GalaxyEngine engine, int cellRange) { + List list = new ArrayList<>(); + for (int cx = -cellRange; cx <= cellRange; cx++) { + for (int cz = -cellRange; cz <= cellRange; cz++) { + engine.islandInCell(cx, cz).ifPresent(list::add); + } + } + return list; + } + + @Test + void testSameSeedSameGalaxy() { + GalaxyEngine a = new GalaxyEngine(config(SEED, 0.5)); + GalaxyEngine b = new GalaxyEngine(config(SEED, 0.5)); + List islandsA = islands(a, 10); + List islandsB = islands(b, 10); + assertFalse(islandsA.isEmpty()); + // Positions, types, bands, biomes AND names all identical + assertEquals(islandsA, islandsB); + } + + @Test + void testDifferentSeedDifferentGalaxy() { + List islandsA = islands(new GalaxyEngine(config(SEED, 0.5)), 10); + List islandsB = islands(new GalaxyEngine(config(SEED + 1, 0.5)), 10); + assertNotEquals(islandsA, islandsB); + } + + @Test + void testQueryOrderIndependence() { + GalaxyEngine a = new GalaxyEngine(config(SEED, 0.5)); + GalaxyEngine b = new GalaxyEngine(config(SEED, 0.5)); + // Query b in reverse order, and via a different entry point first + b.landLiftAt(-31000, 17000); + List reversed = new ArrayList<>(); + for (int cx = 10; cx >= -10; cx--) { + for (int cz = 10; cz >= -10; cz--) { + b.islandInCell(cx, cz).ifPresent(reversed::add); + } + } + assertEquals(islands(a, 10).size(), reversed.size()); + assertTrue(islands(a, 10).containsAll(reversed)); + } + + @Test + void testMinimumSeparation() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); // every cell occupied - worst case + List list = islands(engine, 8); + for (int i = 0; i < list.size(); i++) { + for (int j = i + 1; j < list.size(); j++) { + IslandSpec a = list.get(i); + IslandSpec b = list.get(j); + double dist = Math.sqrt(a.distanceSquared(b.centerX(), b.centerZ())); + assertTrue(dist >= 2500, + "Islands too close: " + a.name() + " and " + b.name() + " at " + dist + " blocks"); + } + } + } + + @Test + void testStarterClusterDensityFloor() { + // Even with density 0, the starter cells host islands - and they are SAFE + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + List list = islands(engine, 5); + assertEquals(5, list.size(), "Starter density floor must guarantee the configured island count"); + list.forEach(s -> assertEquals(SecurityBand.SAFE, s.band(), s.name() + " must be SAFE")); + } + + @Test + void testBandsGetLawlessWithDistance() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + // Far from spawn, islands must be lawless-side; near spawn, safe-side + Optional far = engine.islandInCell(40, 40); // ~283km out + assertTrue(far.isPresent()); + assertEquals(SecurityBand.ANARCHIC, far.get().band()); + Optional near = engine.islandInCell(0, 0); + assertTrue(near.isPresent()); + assertTrue(near.get().band().ordinal() <= SecurityBand.POLICED.ordinal()); + } + + @Test + void testLandLift() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + IslandSpec spec = engine.islandInCell(2, 3).orElseThrow(); + // Full lift at the center + assertEquals(45, engine.landLiftAt(spec.centerX(), spec.centerZ())); + // Tapered on the flank + int flank = engine.landLiftAt(spec.centerX() + 80, spec.centerZ()); + assertTrue(flank > 0 && flank < 45, "Flank lift should taper: " + flank); + // Zero beyond the terrain radius + assertEquals(0, engine.landLiftAt(spec.centerX() + 161, spec.centerZ())); + // Zero in open ocean (empty cell far out with density check impossible at 1.0 -> use ocean point between islands) + assertEquals(0, new GalaxyEngine(config(SEED, 0.0)).landLiftAt(1_000_000, 1_000_000)); + } + + @Test + void testIslandAtAndBiomeKey() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + IslandSpec spec = engine.islandInCell(-4, 6).orElseThrow(); + assertEquals(spec, engine.islandAt(spec.centerX(), spec.centerZ()).orElseThrow()); + assertTrue(engine.islandAt(spec.centerX() + 500, spec.centerZ() + 500).isEmpty()); + assertEquals(spec.biomeKey(), engine.biomeKeyAt(spec.centerX(), spec.centerZ()).orElseThrow()); + assertTrue(spec.type().getBiomeKeys().contains(spec.biomeKey())); + } + + @Test + void testFrozenApproachRing() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + // Find a FROZEN island + IslandSpec frozen = islands(engine, 15).stream().filter(s -> s.type() == IslandType.FROZEN).findFirst() + .orElseThrow(); + // Just outside the terrain radius but inside 2x: frozen ocean + assertEquals(Optional.of("minecraft:frozen_ocean"), + engine.biomeKeyAt(frozen.centerX() + 200, frozen.centerZ())); + } + + @Test + void testNamesAreDistinctEnough() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + List list = islands(engine, 7); + long distinct = list.stream().map(IslandSpec::name).distinct().count(); + // Names are not globally unique by design, but collisions must be rare + assertTrue(distinct > list.size() * 0.8, + "Too many name collisions: " + distinct + " distinct of " + list.size()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/NameGeneratorTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/NameGeneratorTest.java new file mode 100644 index 0000000..6e2783d --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/galaxy/NameGeneratorTest.java @@ -0,0 +1,43 @@ +package world.bentobox.tradewinds.galaxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +/** + * Tests the Elite-style name generator: deterministic, pronounceable-looking, + * bounded length. + * + * @author tastybento + */ +class NameGeneratorTest { + + @Test + void testDeterminism() { + for (long h = -500; h < 500; h++) { + assertEquals(NameGenerator.name(h), NameGenerator.name(h)); + } + } + + @Test + void testShape() { + for (long h = 0; h < 2000; h++) { + String name = NameGenerator.name(h * 0x9E3779B97F4A7C15L); + assertTrue(name.length() >= 3 && name.length() <= 8, "Bad length: " + name); + assertTrue(name.matches("[A-Z][a-z]+"), "Bad shape: " + name); + } + } + + @Test + void testVariety() { + Set names = new HashSet<>(); + for (long h = 0; h < 1000; h++) { + names.add(NameGenerator.name(Hashing.mix(h))); + } + assertTrue(names.size() > 700, "Not enough variety: " + names.size()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 7f5d7b8..917997b 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -57,6 +57,10 @@ public void setUp() throws Exception { addon = mock(TradeWinds.class); settings = new Settings(); when(addon.getSettings()).thenReturn(settings); + // Default: an empty galaxy (density 0, no starter islands) - pure ocean + when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) + .thenReturn(new world.bentobox.tradewinds.galaxy.GalaxyEngine( + new world.bentobox.tradewinds.galaxy.GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000))); } private WorldInfo worldInfo(Environment env, long seed) { @@ -175,23 +179,35 @@ void testNoLandAboveSeaLevelAtScaleOne() { } @Test - void testTerrainScaleHookLiftsLand() { - // Stage 1 readiness: a terrain scale > 1 must lift the floor above sea level - ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon) { - @Override - protected double terrainScale(WorldInfo worldInfo, int worldX, int worldZ) { - return 3.0; + void testTerrainLiftMakesIslands() { + // A galaxy with an island at this chunk must lift grassy land above the sea + world.bentobox.tradewinds.galaxy.GalaxyEngine denseEngine = new world.bentobox.tradewinds.galaxy.GalaxyEngine( + new world.bentobox.tradewinds.galaxy.GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000)); + when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())).thenReturn(denseEngine); + world.bentobox.tradewinds.galaxy.IslandSpec spec = denseEngine.islandInCell(0, 0).orElseThrow(); + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, spec.centerX() >> 4, spec.centerZ() >> 4); + + boolean grassAboveSea = false; + for (int x = 0; x < 16 && !grassAboveSea; x++) { + for (int z = 0; z < 16 && !grassAboveSea; z++) { + for (int y = settings.getSeaHeight() + 1; y < settings.getSeaHeight() + 45; y++) { + if (r.get(x, y, z) == Material.GRASS_BLOCK) { + grassAboveSea = true; + break; + } + } } - }; - RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 0, 0); - boolean landAboveSea = false; - for (int x = 0; x < 16 && !landAboveSea; x++) { - for (int z = 0; z < 16 && !landAboveSea; z++) { - Material m = r.get(x, settings.getSeaHeight() + 1, z); - landAboveSea = m == Material.SAND || m == Material.SANDSTONE; + } + assertTrue(grassAboveSea, "The island mask should lift grassy land above sea level"); + + // And the interstice ignores the galaxy entirely + RecordingChunkData nether = generate(gen, Environment.NETHER, SEED, spec.centerX() >> 4, spec.centerZ() >> 4); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertEquals(Material.AIR, nether.get(x, settings.getIntersticeSeaHeight() + 1, z)); } } - assertTrue(landAboveSea, "terrainScale > 1 should lift terrain above sea level"); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java new file mode 100644 index 0000000..7672493 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java @@ -0,0 +1,120 @@ +package world.bentobox.tradewinds.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +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 java.util.HashMap; +import java.util.Optional; + +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.event.world.ChunkLoadEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import world.bentobox.bentobox.lists.Flags; +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.Settings; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Tests {@link GalaxyIslandRegistrar}: island center chunks register unowned, + * named, band-flagged BentoBox islands exactly once. + * + * @author tastybento + */ +class GalaxyIslandRegistrarTest extends CommonTestSetup { + + private static final long SEED = 31337L; + + private TradeWinds addon; + private GalaxyEngine engine; + private GalaxyIslandRegistrar registrar; + private IslandSpec spec; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + Settings settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); + when(addon.getOverWorld()).thenReturn(world); + when(addon.getIslands()).thenReturn(im); + when(world.getSeed()).thenReturn(SEED); + registrar = new GalaxyIslandRegistrar(addon); + spec = engine.islandInCell(0, 0).orElseThrow(); + } + + private ChunkLoadEvent chunkLoad(int chunkX, int chunkZ) { + Chunk chunk = mock(Chunk.class); + when(chunk.getX()).thenReturn(chunkX); + when(chunk.getZ()).thenReturn(chunkZ); + when(chunk.getWorld()).thenReturn(world); + return new ChunkLoadEvent(chunk, true); + } + + @Test + void testRegistersIslandOnCenterChunkLoad() { + when(im.getIslandAt(any())).thenReturn(Optional.empty()); + when(im.createIsland(any(), isNull())).thenReturn(island); + when(island.getMetaData()).thenReturn(Optional.of(new HashMap<>())); + + registrar.onChunkLoad(chunkLoad(spec.centerX() >> 4, spec.centerZ() >> 4)); + + ArgumentCaptor loc = ArgumentCaptor.forClass(Location.class); + verify(im).createIsland(loc.capture(), isNull()); + assertEquals(spec.centerX() + 0.5, loc.getValue().getX()); + assertEquals(spec.centerZ() + 0.5, loc.getValue().getZ()); + verify(island).setName(spec.name()); + verify(island).setSettingsFlag(Flags.PVP_OVERWORLD, spec.band().isPvp()); + } + + @Test + void testDoesNotRegisterTwice() { + when(im.getIslandAt(any())).thenReturn(Optional.empty()); + when(im.createIsland(any(), isNull())).thenReturn(island); + when(island.getMetaData()).thenReturn(Optional.of(new HashMap<>())); + + registrar.onChunkLoad(chunkLoad(spec.centerX() >> 4, spec.centerZ() >> 4)); + registrar.onChunkLoad(chunkLoad(spec.centerX() >> 4, spec.centerZ() >> 4)); + verify(im).createIsland(any(), isNull()); // exactly once + } + + @Test + void testSkipsAlreadyPersistedIsland() { + // Island already in the database from a previous session + when(im.getIslandAt(any())).thenReturn(Optional.of(island)); + registrar.onChunkLoad(chunkLoad(spec.centerX() >> 4, spec.centerZ() >> 4)); + verify(im, never()).createIsland(any(), any()); + } + + @Test + void testIgnoresChunksWithoutIslandCenter() { + // A chunk two cells away holds no island center + registrar.onChunkLoad(chunkLoad((spec.centerX() >> 4) + 40, (spec.centerZ() >> 4) + 40)); + verify(im, never()).createIsland(any(), any()); + } + + @Test + void testIgnoresOtherWorlds() { + Chunk chunk = mock(Chunk.class); + org.bukkit.World other = mock(org.bukkit.World.class); + when(chunk.getWorld()).thenReturn(other); + registrar.onChunkLoad(new ChunkLoadEvent(chunk, true)); + verify(im, never()).createIsland(any(), any()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java index 2583579..2316367 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java @@ -2,9 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.List; + import org.bukkit.World.Environment; import org.bukkit.block.Biome; import org.bukkit.generator.WorldInfo; @@ -14,6 +17,9 @@ import world.bentobox.tradewinds.CommonTestSetup; import world.bentobox.tradewinds.Settings; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; /** * Tests {@link TradeWindsBiomeProvider}. @@ -22,9 +28,13 @@ */ class TradeWindsBiomeProviderTest extends CommonTestSetup { + private static final long SEED = 424242L; + private TradeWinds addon; private Settings settings; private TradeWindsBiomeProvider provider; + private GalaxyEngine emptyGalaxy; + private GalaxyEngine denseGalaxy; @Override @BeforeEach @@ -33,33 +43,54 @@ public void setUp() throws Exception { addon = mock(TradeWinds.class); settings = new Settings(); when(addon.getSettings()).thenReturn(settings); + emptyGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000)); + denseGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(emptyGalaxy); provider = new TradeWindsBiomeProvider(addon); } private WorldInfo worldInfo(Environment env) { WorldInfo wi = mock(WorldInfo.class); when(wi.getEnvironment()).thenReturn(env); + when(wi.getSeed()).thenReturn(SEED); return wi; } @Test - void testOceanBiomes() { + void testOpenOceanBiomes() { WorldInfo wi = worldInfo(Environment.NORMAL); assertEquals(Biome.OCEAN, provider.getBiome(wi, 0, settings.getSeaHeight(), 0)); assertEquals(Biome.OCEAN, provider.getBiome(wi, 0, 10, 0)); assertEquals(settings.getDefaultAirBiome(), provider.getBiome(wi, 0, settings.getSeaHeight() + 1, 0)); } + @Test + void testIslandBiome() { + when(addon.getGalaxyEngine(anyLong())).thenReturn(denseGalaxy); + IslandSpec spec = denseGalaxy.islandInCell(0, 0).orElseThrow(); + WorldInfo wi = worldInfo(Environment.NORMAL); + Biome islandBiome = provider.getBiome(wi, spec.centerX(), settings.getSeaHeight() + 5, spec.centerZ()); + // The island biome applies to the whole column and is one of the type's table + assertEquals(islandBiome, provider.getBiome(wi, spec.centerX(), 30, spec.centerZ())); + assertTrue(spec.type().getBiomeKeys().contains(islandBiome.getKey().toString()), + "Island biome " + islandBiome.getKey() + " not in type table " + spec.type().getBiomeKeys()); + } + @Test void testIntersticeBiome() { WorldInfo wi = worldInfo(Environment.NETHER); assertEquals(Biome.NETHER_WASTES, provider.getBiome(wi, 0, 64, 0)); - assertEquals(Biome.NETHER_WASTES, provider.getBiome(wi, 0, 100, 0)); + assertEquals(List.of(Biome.NETHER_WASTES), provider.getBiomes(wi)); } @Test - void testGetBiomes() { - assertTrue(provider.getBiomes(worldInfo(Environment.NORMAL)).contains(Biome.OCEAN)); - assertEquals(java.util.List.of(Biome.NETHER_WASTES), provider.getBiomes(worldInfo(Environment.NETHER))); + void testGetBiomesListsAllPossible() { + List biomes = provider.getBiomes(worldInfo(Environment.NORMAL)); + assertTrue(biomes.contains(Biome.OCEAN)); + // All galaxy island biomes must be declared to the world + assertTrue(biomes.contains(Biome.PLAINS)); + assertTrue(biomes.contains(Biome.SNOWY_PLAINS)); + assertTrue(biomes.contains(Biome.FROZEN_OCEAN)); + assertTrue(biomes.contains(Biome.CHERRY_GROVE)); } } From c17ca918b365fa6c136d5e4778342f51e367a7a1 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 16:04:46 -0700 Subject: [PATCH 003/112] Fix playtest round 1: spawn command, free islands, portal leak, discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /tw spawn now teleports to the world ocean spawn (no spawn island needed); world spawn pinned to (0, sea+1, 0) - Player command strips create/reset — player islands are purchase-only - IntersticePortalListener seals both worlds against portal creation and teleports (config flag alone did not stop Multiverse) - /twadmin islands + /twadmin tpisland for island discovery pre-generation - 46 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 45 ++++++--- docs/PROGRESS.md | 26 +++++ .../world/bentobox/tradewinds/TradeWinds.java | 37 ++++++- .../commands/AdminIslandsCommand.java | 69 +++++++++++++ .../commands/AdminTpIslandCommand.java | 62 ++++++++++++ .../tradewinds/commands/TWSpawnCommand.java | 40 ++++++++ .../listeners/IntersticePortalListener.java | 63 ++++++++++++ src/main/resources/locales/en-US.yml | 16 ++- .../bentobox/tradewinds/TradeWindsTest.java | 8 ++ .../commands/AdminIslandsCommandTest.java | 67 +++++++++++++ .../IntersticePortalListenerTest.java | 99 +++++++++++++++++++ 11 files changed, 514 insertions(+), 18 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/AdminTpIslandCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/listeners/IntersticePortalListener.java create mode 100644 src/test/java/world/bentobox/tradewinds/commands/AdminIslandsCommandTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/listeners/IntersticePortalListenerTest.java diff --git a/TESTING.md b/TESTING.md index e790e7e..419cc47 100644 --- a/TESTING.md +++ b/TESTING.md @@ -7,27 +7,46 @@ what only a live server can prove. ## Stage 0 — Addon scaffold -- [ ] Server starts with no errors/warnings from TradeWinds in the console. -- [ ] `bbox version` lists TradeWinds alongside the other gamemodes (AcidIsland, AOneBlock, Gusher…), state ENABLED. -- [ ] Worlds `tradewinds_world` and `tradewinds_world_nether` exist (`mv list` / console log). +- [x] Server starts with no errors/warnings from TradeWinds in the console. +- [x] `bbox version` lists TradeWinds alongside the other gamemodes (AcidIsland, AOneBlock, Gusher…), state ENABLED. +- [x] Worlds `tradewinds_world` and `tradewinds_world_nether` exist (`mv list` / console log). - [ ] `/tw` (and `/tradewinds`) teleports the player to spawn in open ocean — water to the horizon, no land, no vanilla continents. -- [ ] Ocean floor exists (dive down: sand/sandstone floor roughly y25–y50, bedrock at bottom, no caves by default). -- [ ] Above-water world is air up to build height (no floating junk). + - ~~Fail - Running `/tw` reports the error "There is no spawn in this gamemode" and nothing happens.~~ + - ~~Running `/tw create` makes an island for the player with a bedrock.~~ + - FIXED (retest): `/tw spawn` is now a TradeWinds command that teleports straight to the + world spawn on the sea surface (no spawn island needed); `create`/`reset` are removed — + player islands are purchase-only (Stage 7). Also retest: `/tw create` must now be + an unknown command. Delete the stray bedrock island from the earlier test with + `/twadmin delete `. +- [x] Ocean floor exists (dive down: sand/sandstone floor roughly y25–y50, bedrock at bottom, no caves by default). +- [x] Above-water world is air up to build height (no floating junk). - [ ] The interstice is inaccessible: nether portals in `tradewinds_world` do not activate/link (build one and light it), and no command teleports there. -- [ ] Interstice world (teleport there as admin, e.g. `mv tp`): water sea over basalt/soul-sand floor, nether ambience. -- [ ] No `tradewinds_world_the_end` world is created. -- [ ] `/twadmin` responds (admin help). -- [ ] `addons/TradeWinds/config.yml` generated with all Stage 0 sections (galaxy, travel, illegal-trade, world). -- [ ] Restart the server: worlds reload, no duplicate-world or generator errors, chunks unchanged (fly the same area). -- [ ] Other gamemodes still work (create/visit an AcidIsland island). + - ~~FAIL: I built a portal and was able to port there.~~ + - FIXED (retest): a portal listener now cancels portal creation AND portal teleports in + both TradeWinds worlds (the config flag only stopped BentoBox's own linking, not + Multiverse's). Retest: lighting an obsidian frame should do nothing at all; the + portal built during the earlier test should also no longer teleport. +- [x] Interstice world (teleport there as admin, e.g. `mv tp`): water sea over basalt/soul-sand floor, nether ambience. +- [x] No `tradewinds_world_the_end` world is created. +- [x] `/twadmin` responds (admin help). +- [x] `addons/TradeWinds/config.yml` generated with all Stage 0 sections (galaxy, travel, illegal-trade, world). +- [x] Restart the server: worlds reload, no duplicate-world or generator errors, chunks unchanged (fly the same area). +- [x] Other gamemodes still work (create/visit an AcidIsland island). ## Stage 1 — Seeded galaxy Set `galaxy.seed` in `addons/TradeWinds/config.yml` to a known value (e.g. `20260729`) and delete the `tradewinds_world*` folders for a clean generation, then: -- [ ] Console logs `TradeWinds galaxy seed: ` on first world access. -- [ ] Fly (or `/twadmin tp` / creative-fly a boat) toward the nearest island — one of the 5 starter islands should be within ~4–8k blocks of spawn (console logs each registration: name, type, band, coords). +- [x] Console logs `TradeWinds galaxy seed: ` on first world access. +- [ ] Use `/twadmin islands` to list the 10 nearest trading islands (works before any + terrain generates — it queries the galaxy engine), then `/twadmin tpisland 1` to + visit the nearest. The registration log line (name, type, band, coords) appears + when the island's center chunk loads, i.e. on arrival. + - ~~FAIL - no islands logged in console. Cannot TP to any islands either.~~ + - Not a generation bug: islands register lazily when their center chunk first loads, + and nothing had generated chunks 3.5k+ blocks out. The missing piece was a discovery + tool — hence the two new admin commands above. - [ ] Islands rise smoothly from the ocean: underwater shelf → beach → grassy interior; no cliffs of floating terrain, no chunk-border seams, no pop-in (land is generated, not pasted). - [ ] Each island has a single whole-island biome matching its logged type (e.g. MINING → windswept hills; FROZEN → snowy, with **ice sheets in the surrounding water ring** — ride a boat over the ice: it should be fast). - [ ] Entering an island's protection range announces its name ("Now entering "). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 93d5f04..25859bc 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,32 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest round 1 fixes (2026-07-29) + +Ben's first in-game pass (see TESTING.md) found three failures; all fixed: + +1. **`/tw` → "There is no spawn in this gamemode".** Core `IslandSpawnCommand` + needs a spawn *island*, which nothing creates. Replaced with + `TWSpawnCommand` (DelayedTeleportCommand straight to the world spawn on the + sea surface; `World#setSpawnLocation(0, seaHeight+1, 0)` set in onEnable). + Deliberately did NOT create a BentoBox spawn island at origin: a + range-1000 island at 0,0 can overlap a starter island's range (worst-case + jittered center is ~1768 from origin) and would fail grid registration. +2. **`/tw create` gave free bedrock islands.** The default player command set + is wrong for TradeWinds - players buy islands at Stage 7. Player command + setup now registers only `spawn`, `info`, `language`. Lifecycle test locks + this in (create/reset must be absent). +3. **Nether portal reached the interstice.** `create-and-link-portals: false` + only stops BentoBox's own linking; Multiverse handled the portal anyway. + `IntersticePortalListener` now cancels PortalCreateEvent, PlayerPortalEvent + and EntityPortalEvent touching either TradeWinds world at LOWEST priority. +4. **"No islands found" was not a bug but a discoverability gap** - islands + register lazily on center-chunk load and nobody had traveled 3.5k blocks + out. Added `/twadmin islands` (nearest 10 via pure galaxy query, works + pre-generation) and `/twadmin tpisland ` (chunk-loading teleport). + +46 tests green after fixes. + ## Stage 1 — Seeded galaxy (2026-07-29) — CODE COMPLETE, awaiting in-game test **Done:** diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index a8ce12f..34a1ebb 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -15,9 +15,15 @@ import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.commands.admin.DefaultAdminCommand; import world.bentobox.bentobox.api.commands.island.DefaultPlayerCommand; +import world.bentobox.bentobox.api.commands.island.IslandInfoCommand; +import world.bentobox.bentobox.api.commands.island.IslandLanguageCommand; import world.bentobox.bentobox.api.configuration.Config; import world.bentobox.bentobox.api.configuration.WorldSettings; import world.bentobox.bentobox.lists.Flags; +import world.bentobox.tradewinds.commands.AdminIslandsCommand; +import world.bentobox.tradewinds.commands.AdminTpIslandCommand; +import world.bentobox.tradewinds.commands.TWSpawnCommand; +import world.bentobox.tradewinds.listeners.IntersticePortalListener; import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.generator.ChunkGeneratorWorld; @@ -65,9 +71,28 @@ public void onLoad() { this.biomeProvider = new TradeWindsBiomeProvider(this); // Chunk generator chunkGenerator = settings.isUseOwnGenerator() ? null : new ChunkGeneratorWorld(this); - // Register commands - playerCommand = new DefaultPlayerCommand(this) {}; - adminCommand = new DefaultAdminCommand(this) {}; + // Register commands. TradeWinds players own no island until Stage 7, so + // the default create/reset/team/home commands are deliberately absent: + // player islands are purchased, never free. + playerCommand = new DefaultPlayerCommand(this) { + @Override + public void setup() { + setDescription("tradewinds.commands.help.description"); + setOnlyPlayer(true); + setPermission("island"); + new TWSpawnCommand(this); + new IslandInfoCommand(this); + new IslandLanguageCommand(this); + } + }; + adminCommand = new DefaultAdminCommand(this) { + @Override + public void setup() { + super.setup(); + new AdminIslandsCommand(this); + new AdminTpIslandCommand(this); + } + }; } private boolean loadSettings() { @@ -95,6 +120,12 @@ public void onEnable() { Flags.ENTER_EXIT_MESSAGES.setDefaultSetting(islandWorld, true); // Register trading islands lazily as their center chunks first load registerListener(new GalaxyIslandRegistrar(this)); + // Seal both worlds against portals - the interstice is warp-failure-only + registerListener(new IntersticePortalListener(this)); + // Deterministic ocean spawn on the sea surface at the galaxy origin + if (islandWorld != null) { + islandWorld.setSpawnLocation(0, getSettings().getSeaHeight() + 1, 0); + } } @Override diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java new file mode 100644 index 0000000..1155a58 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java @@ -0,0 +1,69 @@ +package world.bentobox.tradewinds.commands; + +import java.util.Comparator; +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Lists the trading islands nearest the caller (galaxy-engine query, so it + * works before any chunks are generated - this is the discovery tool for + * admins and testing). The index shown is the argument for + * {@code /twadmin tpisland }. + * + * @author tastybento + */ +public class AdminIslandsCommand extends CompositeCommand { + + /** How far out to search, in blocks. */ + static final int SEARCH_RADIUS = 50_000; + static final int COUNT = 10; + + public AdminIslandsCommand(CompositeCommand parent) { + super(parent, "islands"); + } + + @Override + public void setup() { + setPermission("admin.islands"); + setDescription("tradewinds.commands.admin.islands.description"); + } + + /** + * The trading islands nearest a position, closest first. + */ + static List nearest(TradeWinds addon, int x, int z) { + return addon.getGalaxyEngine(addon.getOverWorld().getSeed()).islandsNear(x, z, SEARCH_RADIUS).stream() + .sorted(Comparator.comparingLong(s -> s.distanceSquared(x, z))).limit(COUNT).toList(); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + int x = user.isPlayer() && getWorld().equals(user.getWorld()) ? user.getLocation().getBlockX() : 0; + int z = user.isPlayer() && getWorld().equals(user.getWorld()) ? user.getLocation().getBlockZ() : 0; + List list = nearest(addon, x, z); + if (list.isEmpty()) { + user.sendMessage("tradewinds.commands.admin.islands.none"); + return true; + } + user.sendMessage("tradewinds.commands.admin.islands.header"); + for (int i = 0; i < list.size(); i++) { + IslandSpec s = list.get(i); + int dist = (int) Math.sqrt(s.distanceSquared(x, z)); + user.sendMessage("tradewinds.commands.admin.islands.entry", + "[index]", String.valueOf(i + 1), + TextVariables.NAME, s.name(), + "[type]", s.type().name(), + "[band]", s.band().getDisplayName(), + "[x]", String.valueOf(s.centerX()), + "[z]", String.valueOf(s.centerZ()), + "[distance]", String.valueOf(dist)); + } + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminTpIslandCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminTpIslandCommand.java new file mode 100644 index 0000000..c364141 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminTpIslandCommand.java @@ -0,0 +1,62 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; + +import org.bukkit.Location; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Teleports an admin to a trading island by its index in the + * {@code /twadmin islands} listing (1 = nearest). Loads the target chunk to + * land on top of the island terrain. + * + * @author tastybento + */ +public class AdminTpIslandCommand extends CompositeCommand { + + public AdminTpIslandCommand(CompositeCommand parent) { + super(parent, "tpisland"); + } + + @Override + public void setup() { + setPermission("admin.tpisland"); + setOnlyPlayer(true); + setParametersHelp("tradewinds.commands.admin.tpisland.parameters"); + setDescription("tradewinds.commands.admin.tpisland.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + int index = 1; + if (!args.isEmpty()) { + try { + index = Integer.parseInt(args.get(0)); + } catch (NumberFormatException e) { + showHelp(this, user); + return false; + } + } + TradeWinds addon = getAddon(); + int x = getWorld().equals(user.getWorld()) ? user.getLocation().getBlockX() : 0; + int z = getWorld().equals(user.getWorld()) ? user.getLocation().getBlockZ() : 0; + List list = AdminIslandsCommand.nearest(addon, x, z); + if (index < 1 || index > list.size()) { + user.sendMessage("tradewinds.commands.admin.tpisland.unknown"); + return false; + } + IslandSpec s = list.get(index - 1); + // Load the center chunk so we can land on the island surface + int y = getWorld().getChunkAt(s.centerX() >> 4, s.centerZ() >> 4).getWorld() + .getHighestBlockYAt(s.centerX(), s.centerZ()) + 1; + Location loc = new Location(getWorld(), s.centerX() + 0.5, y, s.centerZ() + 0.5); + Util.teleportAsync(user.getPlayer(), loc).thenAccept(done -> user.sendMessage( + "tradewinds.commands.admin.tpisland.teleported", "[name]", s.name())); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java new file mode 100644 index 0000000..1950c8a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java @@ -0,0 +1,40 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; + +import org.bukkit.Location; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.commands.DelayedTeleportCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; + +/** + * Teleports the player to the ocean spawn. TradeWinds has no spawn island (and + * players own no island until Stage 7), so unlike the core island spawn command + * this needs no Island object - it goes straight to the world spawn location on + * the sea surface. + * + * @author tastybento + */ +public class TWSpawnCommand extends DelayedTeleportCommand { + + public TWSpawnCommand(CompositeCommand parent) { + super(parent, "spawn"); + } + + @Override + public void setup() { + setPermission("island.spawn"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.spawn.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + Location spawn = getWorld().getSpawnLocation(); + this.delayCommand(user, () -> Util.teleportAsync(user.getPlayer(), spawn) + .thenAccept(done -> user.sendMessage("tradewinds.spawn.teleported"))); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/listeners/IntersticePortalListener.java b/src/main/java/world/bentobox/tradewinds/listeners/IntersticePortalListener.java new file mode 100644 index 0000000..e70c230 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/listeners/IntersticePortalListener.java @@ -0,0 +1,63 @@ +package world.bentobox.tradewinds.listeners; + +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityPortalEvent; +import org.bukkit.event.player.PlayerPortalEvent; +import org.bukkit.event.world.PortalCreateEvent; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Seals the TradeWinds worlds against portals. The interstice must be + * unreachable except by warp failure (Stage 5), and there is no Nether or End + * to travel to - so portals neither form nor teleport in either TradeWinds + * world, regardless of what other plugins (Multiverse etc.) would do with + * them. The config flag world.nether.create-and-link-portals only stops + * BentoBox's own linking; this listener stops everyone else's. + * + * @author tastybento + */ +public class IntersticePortalListener implements Listener { + + private final TradeWinds addon; + + public IntersticePortalListener(TradeWinds addon) { + this.addon = addon; + } + + private boolean inTradeWindsWorld(World world) { + return world != null && (world.equals(addon.getOverWorld()) || world.equals(addon.getNetherWorld())); + } + + /** + * No portal blocks ever form (lighting an obsidian frame does nothing). + */ + @EventHandler(priority = EventPriority.LOWEST) + public void onPortalCreate(PortalCreateEvent event) { + if (inTradeWindsWorld(event.getWorld())) { + event.setCancelled(true); + } + } + + /** + * Belt and braces: even an existing portal block teleports nobody. + */ + @EventHandler(priority = EventPriority.LOWEST) + public void onPlayerPortal(PlayerPortalEvent event) { + if (inTradeWindsWorld(event.getFrom().getWorld()) + || (event.getTo() != null && inTradeWindsWorld(event.getTo().getWorld()))) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onEntityPortal(EntityPortalEvent event) { + if (inTradeWindsWorld(event.getFrom().getWorld()) + || (event.getTo() != null && inTradeWindsWorld(event.getTo().getWorld()))) { + event.setCancelled(true); + } + } +} diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 098fe10..ba749e6 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -10,10 +10,22 @@ tradewinds: overworld: "the open ocean" interstice: "the interstice" commands: + help: + description: "TradeWinds trading command" + spawn: + description: "teleport to the ocean spawn" admin: help: description: "admin command for TradeWinds" - help: - description: "TradeWinds trading command" + islands: + description: "list the nearest trading islands" + none: "&c No trading islands found - is the galaxy density 0?" + header: "&b Nearest trading islands (use &e/twadmin tpisland <#>&b to visit):" + entry: "&e [index]. &f[name] &7- [type], [band] &7at &f[x], [z] &7([distance] blocks)" + tpisland: + description: "teleport to a trading island from the islands list" + parameters: "[number]" + unknown: "&c No island with that number - run /twadmin islands first." + teleported: "&b Arrived at [name]." spawn: teleported: "&b You set sail on the open ocean." diff --git a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java index d5ea9c4..c4a9a1d 100644 --- a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java +++ b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java @@ -138,6 +138,14 @@ void testOnLoad() { assertTrue(addon.getPlayerCommand().isPresent()); assertTrue(addon.getAdminCommand().isPresent()); assertNotNull(addon.getBiomeProvider()); + // Players can reach spawn but CANNOT create free islands - player + // islands are purchased (Stage 7) + assertTrue(addon.getPlayerCommand().get().getSubCommand("spawn").isPresent()); + assertTrue(addon.getPlayerCommand().get().getSubCommand("create").isEmpty()); + assertTrue(addon.getPlayerCommand().get().getSubCommand("reset").isEmpty()); + // Admin discovery commands + assertTrue(addon.getAdminCommand().get().getSubCommand("islands").isPresent()); + assertTrue(addon.getAdminCommand().get().getSubCommand("tpisland").isPresent()); } /** diff --git a/src/test/java/world/bentobox/tradewinds/commands/AdminIslandsCommandTest.java b/src/test/java/world/bentobox/tradewinds/commands/AdminIslandsCommandTest.java new file mode 100644 index 0000000..b69fb31 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/commands/AdminIslandsCommandTest.java @@ -0,0 +1,67 @@ +package world.bentobox.tradewinds.commands; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Tests the nearest-islands query behind /twadmin islands and tpisland. + * + * @author tastybento + */ +class AdminIslandsCommandTest extends CommonTestSetup { + + private static final long SEED = 5555L; + + private TradeWinds addon; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.5, 5, 5000)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); + when(addon.getOverWorld()).thenReturn(world); + when(world.getSeed()).thenReturn(SEED); + } + + @Test + void testNearestSortedAndCapped() { + List list = AdminIslandsCommand.nearest(addon, 0, 0); + assertFalse(list.isEmpty()); + assertTrue(list.size() <= AdminIslandsCommand.COUNT); + // Sorted nearest-first + for (int i = 1; i < list.size(); i++) { + assertTrue(list.get(i - 1).distanceSquared(0, 0) <= list.get(i).distanceSquared(0, 0)); + } + // The starter cluster guarantees the first hits are close to spawn + assertTrue(Math.sqrt(list.get(0).distanceSquared(0, 0)) < 5000, + "Nearest island should be a starter island near spawn"); + } + + @Test + void testNearestFromRemotePosition() { + List remote = AdminIslandsCommand.nearest(addon, 40_000, -40_000); + assertFalse(remote.isEmpty()); + // Nearest to the remote point, not to spawn + assertTrue(Math.sqrt(remote.get(0).distanceSquared(40_000, -40_000)) < 15_000); + assertEquals(remote, remote.stream() + .sorted((a, b) -> Long.compare(a.distanceSquared(40_000, -40_000), b.distanceSquared(40_000, -40_000))) + .toList()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/listeners/IntersticePortalListenerTest.java b/src/test/java/world/bentobox/tradewinds/listeners/IntersticePortalListenerTest.java new file mode 100644 index 0000000..e067873 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/listeners/IntersticePortalListenerTest.java @@ -0,0 +1,99 @@ +package world.bentobox.tradewinds.listeners; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityPortalEvent; +import org.bukkit.event.player.PlayerPortalEvent; +import org.bukkit.event.world.PortalCreateEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Tests {@link IntersticePortalListener}: no portal forms and no portal + * teleports in either TradeWinds world; other worlds are untouched. + * + * @author tastybento + */ +class IntersticePortalListenerTest extends CommonTestSetup { + + private TradeWinds addon; + private IntersticePortalListener listener; + private World overworld; + private World interstice; + private World elsewhere; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + overworld = mock(World.class); + interstice = mock(World.class); + elsewhere = mock(World.class); + when(addon.getOverWorld()).thenReturn(overworld); + when(addon.getNetherWorld()).thenReturn(interstice); + listener = new IntersticePortalListener(addon); + } + + @Test + void testPortalCreationBlockedInTradeWindsWorlds() { + PortalCreateEvent inOverworld = new PortalCreateEvent(List.of(), overworld, mockPlayer, + PortalCreateEvent.CreateReason.FIRE); + listener.onPortalCreate(inOverworld); + assertTrue(inOverworld.isCancelled()); + + PortalCreateEvent inInterstice = new PortalCreateEvent(List.of(), interstice, mockPlayer, + PortalCreateEvent.CreateReason.FIRE); + listener.onPortalCreate(inInterstice); + assertTrue(inInterstice.isCancelled()); + + PortalCreateEvent outside = new PortalCreateEvent(List.of(), elsewhere, mockPlayer, + PortalCreateEvent.CreateReason.FIRE); + listener.onPortalCreate(outside); + assertFalse(outside.isCancelled()); + } + + @Test + void testPlayerPortalBlocked() { + Player player = mock(Player.class); + PlayerPortalEvent fromOverworld = new PlayerPortalEvent(player, new Location(overworld, 0, 70, 0), + new Location(elsewhere, 0, 70, 0)); + listener.onPlayerPortal(fromOverworld); + assertTrue(fromOverworld.isCancelled()); + + // Portals from other worlds must not reach the interstice either + PlayerPortalEvent intoInterstice = new PlayerPortalEvent(player, new Location(elsewhere, 0, 70, 0), + new Location(interstice, 0, 70, 0)); + listener.onPlayerPortal(intoInterstice); + assertTrue(intoInterstice.isCancelled()); + + PlayerPortalEvent unrelated = new PlayerPortalEvent(player, new Location(elsewhere, 0, 70, 0), + new Location(elsewhere, 0, 70, 0)); + listener.onPlayerPortal(unrelated); + assertFalse(unrelated.isCancelled()); + } + + @Test + void testEntityPortalBlocked() { + EntityPortalEvent event = new EntityPortalEvent(mockPlayer, new Location(overworld, 0, 70, 0), + new Location(elsewhere, 0, 70, 0)); + listener.onEntityPortal(event); + assertTrue(event.isCancelled()); + + EntityPortalEvent unrelated = new EntityPortalEvent(mockPlayer, new Location(elsewhere, 0, 70, 0), + new Location(elsewhere, 0, 70, 0)); + listener.onEntityPortal(unrelated); + assertFalse(unrelated.isCancelled()); + } +} From 11204fe349c968225b4fff21b4bda9b0fd2f666d Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 16:33:42 -0700 Subject: [PATCH 004/112] =?UTF-8?q?Stage=202:=20island=20content=20?= =?UTF-8?q?=E2=80=94=20docks,=20market=20plazas,=20villagers,=20golems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deterministic dock: seeded bearing, generator-terraformed stone-brick quay with type-specific plank deck; flattened plaza with blend ring (pure geometry in galaxy package; GalaxyConfig gains seaLevel) - IslandDecorator populator: bell, lantern posts, palette-colored market stalls; 3-5 villagers with per-economy professions and per-biome skins; iron golems scaled by security band; all persistent and PDC-tagged - 55 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 48 ++++- docs/PROGRESS.md | 30 +++ .../world/bentobox/tradewinds/TradeWinds.java | 2 +- .../tradewinds/galaxy/ColumnPlan.java | 27 +++ .../bentobox/tradewinds/galaxy/DockPlan.java | 18 ++ .../tradewinds/galaxy/GalaxyConfig.java | 3 +- .../tradewinds/galaxy/GalaxyEngine.java | 66 ++++++ .../generator/ChunkGeneratorWorld.java | 37 +++- .../tradewinds/generator/IslandDecorator.java | 204 ++++++++++++++++++ .../tradewinds/generator/IslandPalette.java | 62 ++++++ .../commands/AdminIslandsCommandTest.java | 2 +- .../tradewinds/galaxy/GalaxyEngineTest.java | 40 +++- .../generator/ChunkGeneratorWorldTest.java | 42 +++- .../generator/GalaxyIslandRegistrarTest.java | 2 +- .../generator/IslandDecoratorTest.java | 170 +++++++++++++++ .../TradeWindsBiomeProviderTest.java | 4 +- 16 files changed, 731 insertions(+), 26 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/ColumnPlan.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/DockPlan.java create mode 100644 src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java create mode 100644 src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java create mode 100644 src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java diff --git a/TESTING.md b/TESTING.md index 419cc47..f037a01 100644 --- a/TESTING.md +++ b/TESTING.md @@ -10,7 +10,7 @@ what only a live server can prove. - [x] Server starts with no errors/warnings from TradeWinds in the console. - [x] `bbox version` lists TradeWinds alongside the other gamemodes (AcidIsland, AOneBlock, Gusher…), state ENABLED. - [x] Worlds `tradewinds_world` and `tradewinds_world_nether` exist (`mv list` / console log). -- [ ] `/tw` (and `/tradewinds`) teleports the player to spawn in open ocean — water to the horizon, no land, no vanilla continents. +- [x] `/tw` (and `/tradewinds`) teleports the player to spawn in open ocean — water to the horizon, no land, no vanilla continents. - ~~Fail - Running `/tw` reports the error "There is no spawn in this gamemode" and nothing happens.~~ - ~~Running `/tw create` makes an island for the player with a bedrock.~~ - FIXED (retest): `/tw spawn` is now a TradeWinds command that teleports straight to the @@ -20,7 +20,7 @@ what only a live server can prove. `/twadmin delete `. - [x] Ocean floor exists (dive down: sand/sandstone floor roughly y25–y50, bedrock at bottom, no caves by default). - [x] Above-water world is air up to build height (no floating junk). -- [ ] The interstice is inaccessible: nether portals in `tradewinds_world` do not activate/link (build one and light it), and no command teleports there. +- [x] The interstice is inaccessible: nether portals in `tradewinds_world` do not activate/link (build one and light it), and no command teleports there. - ~~FAIL: I built a portal and was able to port there.~~ - FIXED (retest): a portal listener now cancels portal creation AND portal teleports in both TradeWinds worlds (the config flag only stopped BentoBox's own linking, not @@ -39,7 +39,7 @@ Set `galaxy.seed` in `addons/TradeWinds/config.yml` to a known value (e.g. `2026 and delete the `tradewinds_world*` folders for a clean generation, then: - [x] Console logs `TradeWinds galaxy seed: ` on first world access. -- [ ] Use `/twadmin islands` to list the 10 nearest trading islands (works before any +- [x] Use `/twadmin islands` to list the 10 nearest trading islands (works before any terrain generates — it queries the galaxy engine), then `/twadmin tpisland 1` to visit the nearest. The registration log line (name, type, band, coords) appears when the island's center chunk loads, i.e. on arrival. @@ -47,11 +47,37 @@ and delete the `tradewinds_world*` folders for a clean generation, then: - Not a generation bug: islands register lazily when their center chunk first loads, and nothing had generated chunks 3.5k+ blocks out. The missing piece was a discovery tool — hence the two new admin commands above. -- [ ] Islands rise smoothly from the ocean: underwater shelf → beach → grassy interior; no cliffs of floating terrain, no chunk-border seams, no pop-in (land is generated, not pasted). -- [ ] Each island has a single whole-island biome matching its logged type (e.g. MINING → windswept hills; FROZEN → snowy, with **ice sheets in the surrounding water ring** — ride a boat over the ice: it should be fast). -- [ ] Entering an island's protection range announces its name ("Now entering "). -- [ ] `bbox` island info at an island (`/twadmin info` while standing there) shows an unowned island, range 1000, protection 400. -- [ ] Restart the server: the same islands are still registered (no duplicate-registration log lines), names unchanged. -- [ ] Regenerate the world from scratch with the same seed (stop server, delete world folders AND `database/` TradeWinds islands): identical island positions, names, types. -- [ ] Islands are never within sight of one another (min separation 2500). -- [ ] PvP setting: on a LAWLESS/ANARCHIC island the island PVP flag is on; on SAFE it is off. +- [x] Islands rise smoothly from the ocean: underwater shelf → beach → grassy interior; no cliffs of floating terrain, no chunk-border seams, no pop-in (land is generated, not pasted). +- [x] Each island has a single whole-island biome matching its logged type (e.g. MINING → windswept hills; FROZEN → snowy, with **ice sheets in the surrounding water ring** — ride a boat over the ice: it should be fast). +- [x] Entering an island's protection range announces its name ("Now entering "). +- [x] `bbox` island info at an island (`/twadmin info` while standing there) shows an unowned island, range 1000, protection 400. +- [x] Restart the server: the same islands are still registered (no duplicate-registration log lines), names unchanged. +- [x] Regenerate the world from scratch with the same seed (stop server, delete world folders AND `database/` TradeWinds islands): identical island positions, names, types. +- [x] Islands are never within sight of one another (min separation 2500). +- [x] PvP setting: on a LAWLESS/ANARCHIC island the island PVP flag is on; on SAFE it is off. + +## Stage 2 — Island content + +**IMPORTANT: needs freshly generated island chunks.** Docks/plazas/villagers only appear +in chunks generated by this build — visit islands you have NOT been to before +(`/twadmin islands`, pick ones beyond previous exploration), or regenerate the world. + +- [ ] Every island visited has a **dock**: a straight stone-brick quay with a plank deck + one block above the water, running from the island's flank out into open water. + A boat can pull up alongside the deck end. +- [ ] The dock's plank type varies by island type (oak/spruce/acacia/cherry/dark oak). +- [ ] Inland of the dock: a flattened **market plaza** (dirt-path disc) with a bell at + the center, four lantern posts, and 2–4 market stalls (fence posts + colored wool + canopy + barrel). Stall canopy color matches the island type. +- [ ] Plaza edges blend into the terrain — no sheer walls around the plaza disc. +- [ ] **Villagers** (3–5) stand on the plaza, professions matching the island's economy + (FISHING → fishermen; AGRICULTURAL → farmer/butcher/shepherd; MINING → mason/toolsmith; + INDUSTRIAL → smiths; LUXURY → librarian/cleric; …). Skin variant matches the biome + (snowy islands → snow villagers, desert → desert, …). +- [ ] Villagers are on the plaza, NOT at the waterline (shoreline-safety rule). +- [ ] **Iron golems** on the plaza: 3 on SAFE, 2 on POLICED, 1 on FRONTIER/LAWLESS, + none on ANARCHIC. +- [ ] Leave (unload chunks) and return: villagers and golems are still there (persistent, + no despawn). +- [ ] Restart the server and revisit: residents persist, and no duplicate set spawns. +- [ ] Same seed regeneration: dock bearing, plaza position, and stall layout are identical. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 25859bc..09954d4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,36 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 2 — Island content (2026-07-29) — CODE COMPLETE, awaiting in-game test + +Stage 0/1 checklists fully passed in-game before starting this. + +**Done:** +- **Deterministic dock** (open question resolved per the spec's lean): each + island has a seeded bearing; the quay is terraformed by the chunk generator + (principle 6 - generator makes land, nothing is pasted): stone-brick quay, + type-specific plank deck at sea+1, running from the plaza (45% of terrain + radius) out to open water (85%). Market **plaza**: flattened dirt-path disc + at sea+2 with a 10-block blend ring so it meets terrain without cliffs. + Geometry lives in the pure galaxy package (`DockPlan`, `ColumnPlan`, + `GalaxyEngine.columnPlanAt`); `GalaxyConfig` gained `seaLevel`. +- **IslandDecorator** (BlockPopulator, overworld only): when an island's + plaza-center chunk generates - bell landmark, four lantern posts, 2-4 stalls + (fence + type-colored wool canopy + barrel), all island-deterministic + (seeded from the island hash, not the chunk Random). Structures are + code-built; per-type blueprint sets can replace the stall builder later. +- **Residents** via `LimitedRegion.createEntity` → configure → `addEntity` + (Poseidon pattern): 3-5 villagers with professions per island economy + (`IslandPalette`), skin type per biome, persistent + PDC-tagged + `tradewinds:resident`; iron golems by band (SAFE 3 → ANARCHIC 0). Villagers + spawn on the plaza, inland of the quay (shoreline-safety rule). +- Tests: 55 green (dock-plan geometry, column plans, terraform materials, + decorator determinism/placement/residents, golem counts, villager types). + +**Pitfalls:** +- In test code, fully-qualified `world.bentobox...` names inside methods are + shadowed by CommonTestSetup's `protected World world` field - use imports. + ## Playtest round 1 fixes (2026-07-29) Ben's first in-game pass (see TESTING.md) found three failures; all fixed: diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 34a1ebb..c5b60a8 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -232,7 +232,7 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { long seed = s.getGalaxySeed() != 0 ? s.getGalaxySeed() : worldSeed; galaxyEngine = new GalaxyEngine(new GalaxyConfig(seed, s.getGalaxyMinSeparation(), s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), - s.getStarterClusterMinIslands(), s.getBandRadius())); + s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight())); log("TradeWinds galaxy seed: " + seed); } return galaxyEngine; diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/ColumnPlan.java b/src/main/java/world/bentobox/tradewinds/galaxy/ColumnPlan.java new file mode 100644 index 0000000..bc73fe4 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/ColumnPlan.java @@ -0,0 +1,27 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Terraform instruction for one world column that falls on an island's dock or + * plaza. {@code surfaceY} is the Y of the intended top solid block; {@code blend} + * is 1 inside the feature and falls to 0 across the transition ring so the + * flattened plaza meets natural terrain without cliffs. + * + * @param feature what this column is part of + * @param island the island owning the feature + * @param surfaceY Y of the top solid block the feature wants + * @param blend 1 = fully flattened, less than 1 = transition ring toward natural terrain + * + * @author tastybento + */ +public record ColumnPlan(Feature feature, IslandSpec island, int surfaceY, double blend) { + + /** + * Feature kind for a planned column. + */ + public enum Feature { + /** Market plaza - flat, inland, where stalls and villagers live. */ + PLAZA, + /** Dock quay - solid pier at the waterline, running from plaza to open water. */ + DOCK + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/DockPlan.java b/src/main/java/world/bentobox/tradewinds/galaxy/DockPlan.java new file mode 100644 index 0000000..68a55b9 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/DockPlan.java @@ -0,0 +1,18 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Where an island's dock and market plaza sit - pure geometry derived from the + * island's seeded bearing. The plaza is inland of the quay so villagers stay + * behind the waterline, out of reach of drive-by boat raids. + * + * @param island the island this plan belongs to + * @param bearing dock bearing from the island center, radians + * @param plazaX plaza center block x + * @param plazaZ plaza center block z + * @param plazaRadius plaza radius in blocks + * @param dockEnd distance from island center to the seaward end of the quay + * + * @author tastybento + */ +public record DockPlan(IslandSpec island, double bearing, int plazaX, int plazaZ, int plazaRadius, int dockEnd) { +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java index c773c32..86ae340 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -13,11 +13,12 @@ * @param density chance (0-1) that a galaxy grid cell hosts an island * @param starterMinIslands the guaranteed number of islands nearest spawn (density floor) * @param bandRadius distance from spawn per security-band step, in blocks + * @param seaLevel the world's sea surface Y - dock and plaza heights hang off it * * @author tastybento */ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, - int starterMinIslands, int bandRadius) { + int starterMinIslands, int bandRadius, int seaLevel) { /** * Grid cell size in blocks. With jitter confined to +/- minSeparation/2 of a diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index a194a0a..c03ff44 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -39,6 +39,19 @@ public class GalaxyEngine { private static final long SALT_BAND = 0xBA9D0001L; private static final long SALT_BIOME = 0xB10E0001L; private static final long SALT_NAME = 0x9A3E0001L; + private static final long SALT_DOCK = 0xD0C4B0A7L; + + // Dock/plaza geometry, as fractions of the terrain radius. The plaza sits + // at ~45% out (where natural terrain is already near sea level, so the + // flattening cuts little), the quay runs from the plaza to 85% (open water). + private static final double PLAZA_DIST_FRACTION = 0.45; + private static final double DOCK_END_FRACTION = 0.85; + private static final int PLAZA_BLEND_WIDTH = 10; + private static final int DOCK_HALF_WIDTH = 3; + /** Plaza walking surface sits this many blocks above sea level. */ + public static final int PLAZA_RISE = 2; + /** Dock deck sits this many blocks above sea level. */ + public static final int DOCK_RISE = 1; private final GalaxyConfig config; private final Set starterCells; @@ -214,6 +227,59 @@ public int landLiftAt(int blockX, int blockZ) { return (int) Math.round(config.landLift() * best); } + /** + * The dock/market plan for an island - pure geometry from the island's + * seeded bearing. + * + * @param spec the island + * @return its dock plan + */ + public DockPlan dockPlan(IslandSpec spec) { + double bearing = Hashing.toUnit(Hashing.cellHash(config.seed(), spec.cellX(), spec.cellZ(), SALT_DOCK)) * 2 + * Math.PI; + int plazaDist = (int) (config.terrainRadius() * PLAZA_DIST_FRACTION); + int plazaX = spec.centerX() + (int) Math.round(Math.cos(bearing) * plazaDist); + int plazaZ = spec.centerZ() + (int) Math.round(Math.sin(bearing) * plazaDist); + int plazaRadius = Math.max(12, config.terrainRadius() / 10); + int dockEnd = (int) (config.terrainRadius() * DOCK_END_FRACTION); + return new DockPlan(spec, bearing, plazaX, plazaZ, plazaRadius, dockEnd); + } + + /** + * The terraform instruction for a column that falls on an island's plaza or + * dock, or empty for natural terrain. The plaza is a flattened disc (with a + * blend ring); the dock is a solid quay strip from the plaza to open water. + * + * @param blockX block x + * @param blockZ block z + * @return the column plan, or empty + */ + public Optional columnPlanAt(int blockX, int blockZ) { + for (IslandSpec s : islandsNear(blockX, blockZ, config.terrainRadius())) { + DockPlan plan = dockPlan(s); + // Plaza disc + blend ring + double pd = Math.hypot((double) blockX - plan.plazaX(), (double) blockZ - plan.plazaZ()); + int plazaSurface = config.seaLevel() + PLAZA_RISE; + if (pd <= plan.plazaRadius()) { + return Optional.of(new ColumnPlan(ColumnPlan.Feature.PLAZA, s, plazaSurface, 1.0)); + } + if (pd <= plan.plazaRadius() + PLAZA_BLEND_WIDTH) { + double blend = 1.0 - (pd - plan.plazaRadius()) / PLAZA_BLEND_WIDTH; + return Optional.of(new ColumnPlan(ColumnPlan.Feature.PLAZA, s, plazaSurface, blend)); + } + // Dock strip in bearing-aligned coordinates + double dx = (double) blockX - s.centerX(); + double dz = (double) blockZ - s.centerZ(); + double along = dx * Math.cos(plan.bearing()) + dz * Math.sin(plan.bearing()); + double across = -dx * Math.sin(plan.bearing()) + dz * Math.cos(plan.bearing()); + int plazaDist = (int) (config.terrainRadius() * PLAZA_DIST_FRACTION); + if (along >= plazaDist && along <= plan.dockEnd() && Math.abs(across) <= DOCK_HALF_WIDTH) { + return Optional.of(new ColumnPlan(ColumnPlan.Feature.DOCK, s, config.seaLevel() + DOCK_RISE, 1.0)); + } + } + return Optional.empty(); + } + /** * The biome key governing a column, or empty for default ocean handling. * Within an island's terrain radius this is the island biome; in the diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index b32ddb7..ae87e8a 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -1,6 +1,7 @@ package world.bentobox.tradewinds.generator; import java.util.EnumMap; +import java.util.List; import java.util.Map; import java.util.Random; @@ -8,12 +9,14 @@ import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.BlockPopulator; import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.WorldInfo; import org.bukkit.util.noise.PerlinOctaveGenerator; import org.eclipse.jdt.annotation.NonNull; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.ColumnPlan; /** * Generates the TradeWinds ocean: a noised ocean floor under open sea, everywhere. @@ -54,6 +57,7 @@ Environment.NORMAL, new FloorMats(Material.STONE, Material.SANDSTONE, Material.S private final Map noiseGens = new EnumMap<>(Environment.class); // Deterministic palette randomness; re-seeded per chunk from (world seed, chunk coords) private final Random rand = new Random(); + private IslandDecorator decorator; public ChunkGeneratorWorld(TradeWinds addon) { super(); @@ -113,10 +117,19 @@ public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, double noiseVal = gen.noise(worldX, worldZ, 0.5, 0.5, true); int lift = terrainLift(worldInfo, worldX, worldZ); int floorTop = wc.seaFloor() + (int) (NOISE_MAX + NOISE_MAX * noiseVal) + lift; + // Dock quay and market plaza terraforming (overworld only) + ColumnPlan plan = worldInfo.getEnvironment() == Environment.NORMAL + ? addon.getGalaxyEngine(worldInfo.getSeed()).columnPlanAt(worldX, worldZ).orElse(null) + : null; + if (plan != null) { + int wanted = plan.surfaceY() + 1; + floorTop = plan.blend() >= 1.0 ? wanted + : (int) Math.round(floorTop + (wanted - floorTop) * plan.blend()); + } floorTop = Math.min(floorTop, worldInfo.getMaxHeight() - 1); boolean land = floorTop > wc.seaHeight() + 1; for (int y = wc.seaFloor(); y < floorTop; y++) { - chunkData.setBlock(x, y, z, columnMaterial(mats, y, floorTop, land)); + chunkData.setBlock(x, y, z, columnMaterial(mats, y, floorTop, land, plan)); } // Water column above the floor for (int y = Math.max(floorTop, wc.seaFloor()); y <= wc.seaHeight(); y++) { @@ -130,9 +143,16 @@ public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, * Material for one block of a floor column. Underwater columns are the * sea-floor palette; island columns that clear the sea get a soil profile * (stone core, dirt subsoil, grass on top) so vanilla decoration can plant - * on them. + * on them. Dock columns are a stone-brick quay with a plank deck; fully + * flattened plaza columns get a path surface. */ - private Material columnMaterial(FloorMats mats, int y, int floorTop, boolean land) { + private Material columnMaterial(FloorMats mats, int y, int floorTop, boolean land, ColumnPlan plan) { + if (plan != null && plan.feature() == ColumnPlan.Feature.DOCK) { + return y == floorTop - 1 ? IslandPalette.planks(plan.island().type()) : Material.STONE_BRICKS; + } + if (plan != null && plan.feature() == ColumnPlan.Feature.PLAZA && plan.blend() >= 1.0 && y == floorTop - 1) { + return Material.DIRT_PATH; + } if (!land) { return rand.nextBoolean() ? mats.top() : mats.base(); } @@ -176,6 +196,17 @@ public boolean shouldGenerateStructures() { return addon.getSettings().isMakeStructures(); } + @Override + public List getDefaultPopulators(World world) { + if (world.getEnvironment() != Environment.NORMAL) { + return List.of(); + } + if (decorator == null) { + decorator = new IslandDecorator(addon); + } + return List.of(decorator); + } + @Override public BiomeProvider getDefaultBiomeProvider(WorldInfo worldInfo) { return addon.getBiomeProvider(); diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java new file mode 100644 index 0000000..a724411 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java @@ -0,0 +1,204 @@ +package world.bentobox.tradewinds.generator; + +import java.util.List; +import java.util.Random; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.IronGolem; +import org.bukkit.entity.Villager; +import org.bukkit.generator.BlockPopulator; +import org.bukkit.generator.LimitedRegion; +import org.bukkit.generator.WorldInfo; +import org.bukkit.persistence.PersistentDataType; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.DockPlan; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.Hashing; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Decorates each island's market plaza when its chunk generates: a bell, + * lamp posts, market stalls in the island's palette, resident villagers with + * professions matching the island's economy, and iron golems scaled by + * security band. Everything is placed deterministically from the island's + * seed, inland of the quay (villagers never stand at the waterline). + *

+ * Structures are code-built for now; per-type blueprint sets can replace the + * stall builder later without touching placement or population. + * + * @author tastybento + */ +public class IslandDecorator extends BlockPopulator { + + /** PDC key marking island residents (villagers, golems). */ + public static final NamespacedKey RESIDENT_KEY = NamespacedKey.fromString("tradewinds:resident"); + + private static final long SALT_DECOR = 0xDEC0AA7EL; + + private final TradeWinds addon; + + public IslandDecorator(TradeWinds addon) { + this.addon = addon; + } + + @Override + public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int chunkX, int chunkZ, + @NonNull LimitedRegion limitedRegion) { + if (worldInfo.getEnvironment() != World.Environment.NORMAL) { + return; + } + GalaxyEngine engine = addon.getGalaxyEngine(worldInfo.getSeed()); + // Islands whose plaza center lands in this chunk + int minX = chunkX << 4; + int minZ = chunkZ << 4; + for (IslandSpec spec : engine.islandsNear(minX + 8, minZ + 8, engine.getConfig().terrainRadius() + 16)) { + DockPlan plan = engine.dockPlan(spec); + if (plan.plazaX() >= minX && plan.plazaX() < minX + 16 && plan.plazaZ() >= minZ + && plan.plazaZ() < minZ + 16) { + decoratePlaza(worldInfo, spec, plan, limitedRegion); + } + } + } + + private void decoratePlaza(WorldInfo worldInfo, IslandSpec spec, DockPlan plan, LimitedRegion region) { + // Island-deterministic randomness: the same island decorates the same way + Random rand = new Random(Hashing.cellHash(engineSeed(worldInfo), spec.cellX(), spec.cellZ(), SALT_DECOR)); + int surface = addon.getGalaxyEngine(worldInfo.getSeed()).getConfig().seaLevel() + GalaxyEngine.PLAZA_RISE; + int y = surface + 1; // first air block above the plaza + + // Bell at the plaza center - the market's landmark + setIfPossible(region, plan.plazaX(), y, plan.plazaZ(), Material.BELL); + + // Lamp posts at the four compass points of the plaza edge + int lampR = plan.plazaRadius() - 2; + int[][] lamps = { { lampR, 0 }, { -lampR, 0 }, { 0, lampR }, { 0, -lampR } }; + for (int[] offset : lamps) { + int lx = plan.plazaX() + offset[0]; + int lz = plan.plazaZ() + offset[1]; + setIfPossible(region, lx, y, lz, IslandPalette.fence(spec.type())); + setIfPossible(region, lx, y + 1, lz, IslandPalette.fence(spec.type())); + setIfPossible(region, lx, y + 2, lz, Material.LANTERN); + } + + // Market stalls on a ring: fence corners, wool canopy, a barrel counter + int stalls = 2 + rand.nextInt(3); + double startAngle = rand.nextDouble() * Math.PI * 2; + int stallR = (int) (plan.plazaRadius() * 0.55); + for (int i = 0; i < stalls; i++) { + double angle = startAngle + i * (Math.PI * 2 / stalls); + int sx = plan.plazaX() + (int) Math.round(Math.cos(angle) * stallR); + int sz = plan.plazaZ() + (int) Math.round(Math.sin(angle) * stallR); + buildStall(region, spec, sx, y, sz); + } + + World world = Bukkit.getWorld(worldInfo.getUID()); + spawnResidents(spec, plan, region, rand, world, y); + } + + private long engineSeed(WorldInfo worldInfo) { + return addon.getGalaxyEngine(worldInfo.getSeed()).getConfig().seed(); + } + + 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()); + // Corner posts + for (int dx = -1; dx <= 1; dx += 2) { + for (int dz = -1; dz <= 1; dz += 2) { + setIfPossible(region, sx + dx, y, sz + dz, fence); + setIfPossible(region, sx + dx, y + 1, sz + dz, fence); + } + } + // Canopy + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + setIfPossible(region, sx + dx, y + 2, sz + dz, canopy); + } + } + // Counter + setIfPossible(region, sx, y, sz, Material.BARREL); + } + + private void spawnResidents(IslandSpec spec, DockPlan plan, LimitedRegion region, Random rand, World world, + int y) { + // Villagers: professions match the island's economy + List professions = IslandPalette.professions(spec.type()); + int villagers = 3 + rand.nextInt(3); + for (int i = 0; i < villagers; i++) { + int vx = plan.plazaX() + rand.nextInt(9) - 4; + int vz = plan.plazaZ() + rand.nextInt(9) - 4; + if (!region.isInRegion(vx, y, vz)) { + continue; + } + Location loc = new Location(world, vx + 0.5, y, vz + 0.5); + Villager villager = region.createEntity(loc, Villager.class); + villager.setProfession(professions.get(i % professions.size())); + villager.setVillagerType(villagerType(spec.biomeKey())); + villager.setPersistent(true); + villager.setRemoveWhenFarAway(false); + villager.getPersistentDataContainer().set(RESIDENT_KEY, PersistentDataType.STRING, spec.name()); + region.addEntity(villager); + } + // Resident golems: civilization shows - none in anarchic space + for (int i = 0; i < golemCount(spec.band()); i++) { + int gx = plan.plazaX() + rand.nextInt(13) - 6; + int gz = plan.plazaZ() + rand.nextInt(13) - 6; + if (!region.isInRegion(gx, y, gz)) { + continue; + } + Location loc = new Location(world, gx + 0.5, y, gz + 0.5); + IronGolem golem = region.createEntity(loc, IronGolem.class); + golem.setPersistent(true); + golem.setRemoveWhenFarAway(false); + golem.getPersistentDataContainer().set(RESIDENT_KEY, PersistentDataType.STRING, spec.name()); + region.addEntity(golem); + } + } + + /** + * Resident golems by band - the visible face of island security. + */ + static int golemCount(SecurityBand band) { + return switch (band) { + case SAFE -> 3; + case POLICED -> 2; + case FRONTIER, LAWLESS -> 1; + case ANARCHIC -> 0; + }; + } + + /** + * Villager skin variant matching the island biome. + */ + static Villager.Type villagerType(String biomeKey) { + if (biomeKey.contains("snow") || biomeKey.contains("ice") || biomeKey.contains("frozen")) { + return Villager.Type.SNOW; + } + if (biomeKey.contains("desert") || biomeKey.contains("badlands")) { + return Villager.Type.DESERT; + } + if (biomeKey.contains("savanna")) { + return Villager.Type.SAVANNA; + } + if (biomeKey.contains("mangrove") || biomeKey.contains("swamp")) { + return Villager.Type.SWAMP; + } + if (biomeKey.contains("windswept") || biomeKey.contains("stony")) { + return Villager.Type.TAIGA; + } + return Villager.Type.PLAINS; + } + + private void setIfPossible(LimitedRegion region, int x, int y, int z, Material material) { + if (region.isInRegion(x, y, z)) { + region.setType(x, y, z, material); + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java b/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java new file mode 100644 index 0000000..c4073b3 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java @@ -0,0 +1,62 @@ +package world.bentobox.tradewinds.generator; + +import java.util.List; +import java.util.Map; + +import org.bukkit.Material; +import org.bukkit.entity.Villager; + +import world.bentobox.tradewinds.galaxy.IslandType; + +/** + * The Bukkit-material face of each island type: dock wood, stall canopy color, + * and resident villager professions. Kept out of the galaxy package so that + * stays Bukkit-free. + * + * @author tastybento + */ +public final class IslandPalette { + + private record Palette(Material planks, Material fence, Material canopy, + List professions) { + } + + private static final Map PALETTES = Map.of( + IslandType.AGRICULTURAL, new Palette(Material.OAK_PLANKS, Material.OAK_FENCE, Material.YELLOW_WOOL, + List.of(Villager.Profession.FARMER, Villager.Profession.BUTCHER, Villager.Profession.SHEPHERD)), + IslandType.FOREST, new Palette(Material.DARK_OAK_PLANKS, Material.DARK_OAK_FENCE, Material.GREEN_WOOL, + List.of(Villager.Profession.FLETCHER, Villager.Profession.CARTOGRAPHER)), + IslandType.FISHING, new Palette(Material.OAK_PLANKS, Material.OAK_FENCE, Material.BLUE_WOOL, + List.of(Villager.Profession.FISHERMAN, Villager.Profession.FISHERMAN, + Villager.Profession.LEATHERWORKER)), + IslandType.MINING, new Palette(Material.SPRUCE_PLANKS, Material.SPRUCE_FENCE, Material.GRAY_WOOL, + List.of(Villager.Profession.MASON, Villager.Profession.TOOLSMITH)), + IslandType.INDUSTRIAL, new Palette(Material.ACACIA_PLANKS, Material.ACACIA_FENCE, Material.ORANGE_WOOL, + List.of(Villager.Profession.TOOLSMITH, Villager.Profession.WEAPONSMITH, + Villager.Profession.ARMORER)), + IslandType.LUXURY, new Palette(Material.CHERRY_PLANKS, Material.CHERRY_FENCE, Material.MAGENTA_WOOL, + List.of(Villager.Profession.LIBRARIAN, Villager.Profession.CLERIC, + Villager.Profession.CARTOGRAPHER)), + IslandType.FROZEN, new Palette(Material.SPRUCE_PLANKS, Material.SPRUCE_FENCE, Material.LIGHT_BLUE_WOOL, + List.of(Villager.Profession.LEATHERWORKER, Villager.Profession.CLERIC))); + + private IslandPalette() { + // Static use only + } + + public static Material planks(IslandType type) { + return PALETTES.get(type).planks(); + } + + public static Material fence(IslandType type) { + return PALETTES.get(type).fence(); + } + + public static Material canopy(IslandType type) { + return PALETTES.get(type).canopy(); + } + + public static List professions(IslandType type) { + return PALETTES.get(type).professions(); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/commands/AdminIslandsCommandTest.java b/src/test/java/world/bentobox/tradewinds/commands/AdminIslandsCommandTest.java index b69fb31..159863f 100644 --- a/src/test/java/world/bentobox/tradewinds/commands/AdminIslandsCommandTest.java +++ b/src/test/java/world/bentobox/tradewinds/commands/AdminIslandsCommandTest.java @@ -34,7 +34,7 @@ class AdminIslandsCommandTest extends CommonTestSetup { public void setUp() throws Exception { super.setUp(); addon = mock(TradeWinds.class); - GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.5, 5, 5000)); + GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.5, 5, 5000, 70)); when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); when(addon.getOverWorld()).thenReturn(world); when(world.getSeed()).thenReturn(SEED); diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index 90f319c..eee116d 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -21,7 +21,7 @@ class GalaxyEngineTest { private static final long SEED = 987654321L; private GalaxyConfig config(long seed, double density) { - return new GalaxyConfig(seed, 2500, 160, 45, density, 5, 5000); + return new GalaxyConfig(seed, 2500, 160, 45, density, 5, 5000, 70); } private List islands(GalaxyEngine engine, int cellRange) { @@ -140,6 +140,44 @@ void testFrozenApproachRing() { engine.biomeKeyAt(frozen.centerX() + 200, frozen.centerZ())); } + @Test + void testDockPlanGeometry() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + IslandSpec spec = engine.islandInCell(3, -2).orElseThrow(); + DockPlan plan = engine.dockPlan(spec); + // Deterministic + assertEquals(plan, engine.dockPlan(spec)); + assertEquals(plan, new GalaxyEngine(config(SEED, 1.0)).dockPlan(spec)); + // Plaza sits inside the island's terrain footprint + double plazaDist = Math.sqrt(spec.distanceSquared(plan.plazaX(), plan.plazaZ())); + assertTrue(plazaDist < 160, "Plaza outside terrain: " + plazaDist); + // Quay ends inside the terrain radius but beyond the plaza + assertTrue(plan.dockEnd() > plazaDist && plan.dockEnd() < 160); + } + + @Test + void testColumnPlans() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + IslandSpec spec = engine.islandInCell(1, 1).orElseThrow(); + DockPlan plan = engine.dockPlan(spec); + // Plaza center: fully flattened at sea level + PLAZA_RISE + ColumnPlan plaza = engine.columnPlanAt(plan.plazaX(), plan.plazaZ()).orElseThrow(); + assertEquals(ColumnPlan.Feature.PLAZA, plaza.feature()); + assertEquals(70 + GalaxyEngine.PLAZA_RISE, plaza.surfaceY()); + assertEquals(1.0, plaza.blend()); + assertEquals(spec, plaza.island()); + // Seaward end of the quay: DOCK at sea level + DOCK_RISE + int dockX = spec.centerX() + (int) Math.round(Math.cos(plan.bearing()) * (plan.dockEnd() - 2)); + int dockZ = spec.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * (plan.dockEnd() - 2)); + ColumnPlan dock = engine.columnPlanAt(dockX, dockZ).orElseThrow(); + assertEquals(ColumnPlan.Feature.DOCK, dock.feature()); + assertEquals(70 + GalaxyEngine.DOCK_RISE, dock.surfaceY()); + // Island center is natural terrain (no feature) + assertTrue(engine.columnPlanAt(spec.centerX(), spec.centerZ()).isEmpty()); + // Open ocean has no plans + assertTrue(new GalaxyEngine(config(SEED, 0.0)).columnPlanAt(500_000, 500_000).isEmpty()); + } + @Test void testNamesAreDistinctEnough() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 917997b..999373e 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -21,6 +21,10 @@ import world.bentobox.tradewinds.CommonTestSetup; import world.bentobox.tradewinds.Settings; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.DockPlan; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; /** * Tests the ocean generation of {@link ChunkGeneratorWorld}: determinism, sea @@ -59,8 +63,8 @@ public void setUp() throws Exception { when(addon.getSettings()).thenReturn(settings); // Default: an empty galaxy (density 0, no starter islands) - pure ocean when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) - .thenReturn(new world.bentobox.tradewinds.galaxy.GalaxyEngine( - new world.bentobox.tradewinds.galaxy.GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000))); + .thenReturn(new GalaxyEngine( + new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70))); } private WorldInfo worldInfo(Environment env, long seed) { @@ -181,10 +185,10 @@ void testNoLandAboveSeaLevelAtScaleOne() { @Test void testTerrainLiftMakesIslands() { // A galaxy with an island at this chunk must lift grassy land above the sea - world.bentobox.tradewinds.galaxy.GalaxyEngine denseEngine = new world.bentobox.tradewinds.galaxy.GalaxyEngine( - new world.bentobox.tradewinds.galaxy.GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000)); + world.bentobox.tradewinds.galaxy.GalaxyEngine denseEngine = new GalaxyEngine( + new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())).thenReturn(denseEngine); - world.bentobox.tradewinds.galaxy.IslandSpec spec = denseEngine.islandInCell(0, 0).orElseThrow(); + IslandSpec spec = denseEngine.islandInCell(0, 0).orElseThrow(); ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, spec.centerX() >> 4, spec.centerZ() >> 4); @@ -210,6 +214,34 @@ void testTerrainLiftMakesIslands() { } } + @Test + void testPlazaAndDockTerraform() { + world.bentobox.tradewinds.galaxy.GalaxyEngine denseEngine = new GalaxyEngine( + new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())).thenReturn(denseEngine); + IslandSpec spec = denseEngine.islandInCell(0, 0).orElseThrow(); + DockPlan plan = denseEngine.dockPlan(spec); + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + + // Plaza center chunk: flat dirt-path surface at seaHeight + PLAZA_RISE + RecordingChunkData plaza = generate(gen, Environment.NORMAL, SEED, plan.plazaX() >> 4, plan.plazaZ() >> 4); + int px = plan.plazaX() & 15; + int pz = plan.plazaZ() & 15; + int plazaSurface = settings.getSeaHeight() + GalaxyEngine.PLAZA_RISE; + assertEquals(Material.DIRT_PATH, plaza.get(px, plazaSurface, pz)); + assertEquals(Material.AIR, plaza.get(px, plazaSurface + 1, pz)); + + // A point on the quay: plank deck at seaHeight + DOCK_RISE over stone bricks, no water above + int dockDist = plan.dockEnd() - 4; + int dx = spec.centerX() + (int) Math.round(Math.cos(plan.bearing()) * dockDist); + int dz = spec.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * dockDist); + RecordingChunkData dock = generate(gen, Environment.NORMAL, SEED, dx >> 4, dz >> 4); + int deckY = settings.getSeaHeight() + GalaxyEngine.DOCK_RISE; + assertEquals(IslandPalette.planks(spec.type()), dock.get(dx & 15, deckY, dz & 15)); + assertEquals(Material.STONE_BRICKS, dock.get(dx & 15, deckY - 1, dz & 15)); + assertEquals(Material.AIR, dock.get(dx & 15, deckY + 1, dz & 15)); + } + @Test void testShouldGenerateFlags() { ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); diff --git a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java index 7672493..5b64735 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java @@ -50,7 +50,7 @@ public void setUp() throws Exception { addon = mock(TradeWinds.class); Settings settings = new Settings(); when(addon.getSettings()).thenReturn(settings); - engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000)); + engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); when(addon.getOverWorld()).thenReturn(world); when(addon.getIslands()).thenReturn(im); diff --git a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java new file mode 100644 index 0000000..ba09c31 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java @@ -0,0 +1,170 @@ +package world.bentobox.tradewinds.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +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 java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World.Environment; +import org.bukkit.entity.IronGolem; +import org.bukkit.entity.Villager; +import org.bukkit.generator.LimitedRegion; +import org.bukkit.generator.WorldInfo; +import org.bukkit.persistence.PersistentDataContainer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; + +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.DockPlan; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Tests {@link IslandDecorator}: the plaza chunk gets its bell, stalls and + * residents; other chunks are untouched. + * + * @author tastybento + */ +class IslandDecoratorTest extends CommonTestSetup { + + private static final long SEED = 777L; + + private TradeWinds addon; + private GalaxyEngine engine; + private IslandDecorator decorator; + private IslandSpec spec; + private DockPlan plan; + + private final List placed = new ArrayList<>(); + private final List villagers = new ArrayList<>(); + private final List golems = new ArrayList<>(); + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); + decorator = new IslandDecorator(addon); + spec = engine.islandInCell(0, 0).orElseThrow(); + plan = engine.dockPlan(spec); + } + + private WorldInfo worldInfo(Environment env) { + WorldInfo wi = mock(WorldInfo.class); + when(wi.getEnvironment()).thenReturn(env); + when(wi.getSeed()).thenReturn(SEED); + when(wi.getUID()).thenReturn(java.util.UUID.randomUUID()); + return wi; + } + + private LimitedRegion region() { + LimitedRegion region = mock(LimitedRegion.class); + when(region.isInRegion(anyInt(), anyInt(), anyInt())).thenReturn(true); + org.mockito.Mockito.doAnswer((Answer) inv -> { + placed.add(inv.getArgument(3)); + return null; + }).when(region).setType(anyInt(), anyInt(), anyInt(), any(Material.class)); + when(region.createEntity(any(Location.class), any())).thenAnswer(inv -> { + Class clazz = inv.getArgument(1); + if (clazz == Villager.class) { + Villager villager = mock(Villager.class); + when(villager.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); + villagers.add(villager); + return villager; + } + IronGolem golem = mock(IronGolem.class); + when(golem.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); + golems.add(golem); + return golem; + }); + return region; + } + + @Test + void testPlazaChunkIsDecorated() { + LimitedRegion region = region(); + decorator.populate(worldInfo(Environment.NORMAL), new Random(1), plan.plazaX() >> 4, plan.plazaZ() >> 4, + region); + // Landmark, lamps and stalls + assertTrue(placed.contains(Material.BELL), "No bell placed"); + assertTrue(placed.contains(Material.LANTERN), "No lanterns placed"); + assertTrue(placed.contains(IslandPalette.canopy(spec.type())), "No stall canopy placed"); + assertTrue(placed.contains(Material.BARREL), "No stall counter placed"); + // Residents + assertFalse(villagers.isEmpty(), "No villagers spawned"); + assertEquals(IslandDecorator.golemCount(spec.band()), golems.size()); + // Professions come from the island type's list + verify(villagers.get(0)).setProfession(IslandPalette.professions(spec.type()).get(0)); + verify(villagers.get(0)).setPersistent(true); + verify(villagers.get(0)).setRemoveWhenFarAway(false); + } + + @Test + void testDeterministicDecoration() { + LimitedRegion region = region(); + decorator.populate(worldInfo(Environment.NORMAL), new Random(1), plan.plazaX() >> 4, plan.plazaZ() >> 4, + region); + List first = new ArrayList<>(placed); + int villagerCount = villagers.size(); + placed.clear(); + // Different java Random, same island -> identical build + decorator.populate(worldInfo(Environment.NORMAL), new Random(999), plan.plazaX() >> 4, plan.plazaZ() >> 4, + region()); + assertEquals(first, placed.subList(0, first.size())); + assertEquals(villagerCount * 2, villagers.size()); + } + + @Test + void testNonPlazaChunkUntouched() { + LimitedRegion region = region(); + decorator.populate(worldInfo(Environment.NORMAL), new Random(1), (plan.plazaX() >> 4) + 10, + (plan.plazaZ() >> 4) + 10, region); + verify(region, never()).setType(anyInt(), anyInt(), anyInt(), any(Material.class)); + assertTrue(villagers.isEmpty()); + } + + @Test + void testIntersticeNeverDecorated() { + LimitedRegion region = region(); + decorator.populate(worldInfo(Environment.NETHER), new Random(1), plan.plazaX() >> 4, plan.plazaZ() >> 4, + region); + verify(region, never()).setType(anyInt(), anyInt(), anyInt(), any(Material.class)); + } + + @Test + void testGolemCountsByBand() { + assertEquals(3, IslandDecorator.golemCount(SecurityBand.SAFE)); + assertEquals(2, IslandDecorator.golemCount(SecurityBand.POLICED)); + assertEquals(1, IslandDecorator.golemCount(SecurityBand.FRONTIER)); + assertEquals(1, IslandDecorator.golemCount(SecurityBand.LAWLESS)); + assertEquals(0, IslandDecorator.golemCount(SecurityBand.ANARCHIC)); + } + + @Test + void testVillagerTypeByBiome() { + assertEquals(Villager.Type.SNOW, IslandDecorator.villagerType("minecraft:snowy_plains")); + assertEquals(Villager.Type.DESERT, IslandDecorator.villagerType("minecraft:badlands")); + assertEquals(Villager.Type.SAVANNA, IslandDecorator.villagerType("minecraft:savanna_plateau")); + assertEquals(Villager.Type.SWAMP, IslandDecorator.villagerType("minecraft:mangrove_swamp")); + assertEquals(Villager.Type.TAIGA, IslandDecorator.villagerType("minecraft:windswept_hills")); + assertEquals(Villager.Type.PLAINS, IslandDecorator.villagerType("minecraft:plains")); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java index 2316367..dcdb34a 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java @@ -43,8 +43,8 @@ public void setUp() throws Exception { addon = mock(TradeWinds.class); settings = new Settings(); when(addon.getSettings()).thenReturn(settings); - emptyGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000)); - denseGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000)); + emptyGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70)); + denseGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); when(addon.getGalaxyEngine(anyLong())).thenReturn(emptyGalaxy); provider = new TradeWindsBiomeProvider(addon); } From 0ea5242029813296d9e42c93db12a3d4418001ee Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 16:55:30 -0700 Subject: [PATCH 005/112] Fix Stage 2 playtest: dock-to-shore gap and villager profession loss - Dock strip now takes precedence over the plaza blend ring, so the quay runs unbroken from plaza edge to pier end (was cut ~10 blocks offshore by the ring blending to submerged terrain); regression test walks the dock axis on 12 islands - Villagers spawn with 1 trade XP to lock their profession (zero-XP villagers were reset to unemployed or claimed stall barrels and turned fisherman) - 56 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 26 ++++++++++++++----- docs/PROGRESS.md | 12 +++++++++ .../tradewinds/galaxy/GalaxyEngine.java | 16 +++++++----- .../tradewinds/generator/IslandDecorator.java | 5 ++++ .../tradewinds/galaxy/GalaxyEngineTest.java | 20 ++++++++++++++ .../generator/IslandDecoratorTest.java | 3 +++ 6 files changed, 69 insertions(+), 13 deletions(-) diff --git a/TESTING.md b/TESTING.md index f037a01..85f93b7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -65,19 +65,31 @@ in chunks generated by this build — visit islands you have NOT been to before - [ ] Every island visited has a **dock**: a straight stone-brick quay with a plank deck one block above the water, running from the island's flank out into open water. A boat can pull up alongside the deck end. -- [ ] The dock's plank type varies by island type (oak/spruce/acacia/cherry/dark oak). -- [ ] Inland of the dock: a flattened **market plaza** (dirt-path disc) with a bell at + - ~~see screenshot at /Users/ben/Downloads/2026-07-29_16.40.38.png. The dock always has a gap between it and the land. It'd be better to have it join the land, otherwise it's not going to be used much.~~ + - FIXED (retest, needs fresh chunks): the plaza's blend ring took precedence over the + dock strip, so the ring blended down to submerged terrain on the seaward side and + cut the quay off ~10 blocks from shore. The dock strip now wins over the ring: the + deck runs unbroken from the plaza edge to the pier end (one step down from plaza + to deck). A regression test walks the full dock axis on 12 islands. +- [x] The dock's plank type varies by island type (oak/spruce/acacia/cherry/dark oak). +- [x] Inland of the dock: a flattened **market plaza** (dirt-path disc) with a bell at the center, four lantern posts, and 2–4 market stalls (fence posts + colored wool canopy + barrel). Stall canopy color matches the island type. -- [ ] Plaza edges blend into the terrain — no sheer walls around the plaza disc. +- [x] Plaza edges blend into the terrain — no sheer walls around the plaza disc. - [ ] **Villagers** (3–5) stand on the plaza, professions matching the island's economy (FISHING → fishermen; AGRICULTURAL → farmer/butcher/shepherd; MINING → mason/toolsmith; INDUSTRIAL → smiths; LUXURY → librarian/cleric; …). Skin variant matches the biome (snowy islands → snow villagers, desert → desert, …). -- [ ] Villagers are on the plaza, NOT at the waterline (shoreline-safety rule). -- [ ] **Iron golems** on the plaza: 3 on SAFE, 2 on POLICED, 1 on FRONTIER/LAWLESS, + - ~~FAIL - they are always Fishermen, or no profession.~~ + - FIXED (retest, needs fresh chunks): vanilla resets a zero-XP villager with no + claimed job site to unemployed — and the stall barrels are fisherman job sites, + so the rest converged on Fisherman. Spawned villagers now carry 1 trade XP, + which locks the assigned profession permanently. Existing villagers from the + previous build will stay broken; only newly generated islands count. +- [x] Villagers are on the plaza, NOT at the waterline (shoreline-safety rule). +- [x] **Iron golems** on the plaza: 3 on SAFE, 2 on POLICED, 1 on FRONTIER/LAWLESS, none on ANARCHIC. -- [ ] Leave (unload chunks) and return: villagers and golems are still there (persistent, +- [x] Leave (unload chunks) and return: villagers and golems are still there (persistent, no despawn). -- [ ] Restart the server and revisit: residents persist, and no duplicate set spawns. +- [x] Restart the server and revisit: residents persist, and no duplicate set spawns. - [ ] Same seed regeneration: dock bearing, plaza position, and stall layout are identical. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 09954d4..c464bb7 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -33,6 +33,18 @@ Stage 0/1 checklists fully passed in-game before starting this. - In test code, fully-qualified `world.bentobox...` names inside methods are shadowed by CommonTestSetup's `protected World world` field - use imports. +**Playtest round 2 fixes (same day):** +- **Dock gap:** the plaza blend ring outranked the dock strip in + `columnPlanAt`, and on the seaward side the ring blends toward submerged + natural terrain - the quay started ~10 blocks offshore. Order is now plaza + disc → dock strip → blend ring, so the deck runs unbroken from plaza edge + to pier end. Regression test walks the full dock axis on 12 islands. +- **Villager professions collapsed to Fisherman/none:** vanilla villager + brains reset a zero-XP, no-job-site villager to unemployed, and stall + BARRELs are fisherman job sites, so the survivors all claimed those. + `setVillagerExperience(1)` at spawn locks the assigned profession. (Known + vanilla behavior - worth remembering for any future spawned-NPC work.) + ## Playtest round 1 fixes (2026-07-29) Ben's first in-game pass (see TESTING.md) found three failures; all fixed: diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index c03ff44..cf8c35f 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -257,17 +257,16 @@ public DockPlan dockPlan(IslandSpec spec) { public Optional columnPlanAt(int blockX, int blockZ) { for (IslandSpec s : islandsNear(blockX, blockZ, config.terrainRadius())) { DockPlan plan = dockPlan(s); - // Plaza disc + blend ring double pd = Math.hypot((double) blockX - plan.plazaX(), (double) blockZ - plan.plazaZ()); int plazaSurface = config.seaLevel() + PLAZA_RISE; + // Plaza disc proper if (pd <= plan.plazaRadius()) { return Optional.of(new ColumnPlan(ColumnPlan.Feature.PLAZA, s, plazaSurface, 1.0)); } - if (pd <= plan.plazaRadius() + PLAZA_BLEND_WIDTH) { - double blend = 1.0 - (pd - plan.plazaRadius()) / PLAZA_BLEND_WIDTH; - return Optional.of(new ColumnPlan(ColumnPlan.Feature.PLAZA, s, plazaSurface, blend)); - } - // Dock strip in bearing-aligned coordinates + // Dock strip in bearing-aligned coordinates. Checked BEFORE the plaza + // blend ring: the ring blends toward natural (often submerged) terrain + // on the seaward side, which used to cut a water gap between the plaza + // and the quay. The quay must run unbroken from plaza edge to pier end. double dx = (double) blockX - s.centerX(); double dz = (double) blockZ - s.centerZ(); double along = dx * Math.cos(plan.bearing()) + dz * Math.sin(plan.bearing()); @@ -276,6 +275,11 @@ public Optional columnPlanAt(int blockX, int blockZ) { if (along >= plazaDist && along <= plan.dockEnd() && Math.abs(across) <= DOCK_HALF_WIDTH) { return Optional.of(new ColumnPlan(ColumnPlan.Feature.DOCK, s, config.seaLevel() + DOCK_RISE, 1.0)); } + // Plaza blend ring + if (pd <= plan.plazaRadius() + PLAZA_BLEND_WIDTH) { + double blend = 1.0 - (pd - plan.plazaRadius()) / PLAZA_BLEND_WIDTH; + return Optional.of(new ColumnPlan(ColumnPlan.Feature.PLAZA, s, plazaSurface, blend)); + } } return Optional.empty(); } diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java index a724411..d70facf 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java @@ -140,6 +140,11 @@ private void spawnResidents(IslandSpec spec, DockPlan plan, LimitedRegion region Location loc = new Location(world, vx + 0.5, y, vz + 0.5); Villager villager = region.createEntity(loc, Villager.class); villager.setProfession(professions.get(i % professions.size())); + // A villager with zero trade XP and no claimed job site is reset to + // unemployed on first tick (and the stall barrels are fisherman job + // sites, so the survivors all turned fisherman). One XP point locks + // the assigned profession for good. + villager.setVillagerExperience(1); villager.setVillagerType(villagerType(spec.biomeKey())); villager.setPersistent(true); villager.setRemoveWhenFarAway(false); diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index eee116d..a159dce 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -178,6 +178,26 @@ void testColumnPlans() { assertTrue(new GalaxyEngine(config(SEED, 0.0)).columnPlanAt(500_000, 500_000).isEmpty()); } + @Test + void testWalkwayIsContinuousFromPlazaToPierEnd() { + // Playtest regression: the quay used to start ~10 blocks offshore because + // the plaza blend ring beat the dock strip. Walking the dock axis from the + // plaza center to the pier end must never leave planned ground. + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + for (int cell = 0; cell < 12; cell++) { + IslandSpec spec = engine.islandInCell(cell, -cell - 1).orElseThrow(); + DockPlan plan = engine.dockPlan(spec); + int plazaDist = (int) Math.round(Math.sqrt(spec.distanceSquared(plan.plazaX(), plan.plazaZ()))); + for (int d = plazaDist; d <= plan.dockEnd() - 1; d++) { + int x = spec.centerX() + (int) Math.round(Math.cos(plan.bearing()) * d); + int z = spec.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * d); + ColumnPlan plaza = engine.columnPlanAt(x, z).orElse(null); + assertTrue(plaza != null && (plaza.blend() >= 1.0), + spec.name() + ": walkway hole at distance " + d + " (" + plaza + ")"); + } + } + } + @Test void testNamesAreDistinctEnough() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); diff --git a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java index ba09c31..3621da3 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java @@ -115,6 +115,9 @@ void testPlazaChunkIsDecorated() { verify(villagers.get(0)).setProfession(IslandPalette.professions(spec.type()).get(0)); verify(villagers.get(0)).setPersistent(true); verify(villagers.get(0)).setRemoveWhenFarAway(false); + // Playtest regression: without trade XP the brain resets professions to + // unemployed (or the stall barrels turn everyone fisherman) + villagers.forEach(v -> verify(v).setVillagerExperience(1)); } @Test From 02676adfc097e010221bd30f740665ac30be9a06 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 17:52:14 -0700 Subject: [PATCH 006/112] =?UTF-8?q?Stage=202b:=20island=20identity=20?= =?UTF-8?q?=E2=80=94=20type=20landmarks,=20dressing,=20configurable=20weig?= =?UTF-8?q?hts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Signature landmark per island type (INDUSTRIAL smoking chimney, MINING shaft head, AGRICULTURAL wheat plot, FISHING smokehouse + moored boat, FOREST log pile, LUXURY fountain, FROZEN ice beacon) - Plaza surface material per type; type-colored banner + lantern at pier end; profession workstations beside stalls - galaxy.type-weights config (GalaxyConfig fallback to enum defaults) - 58 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 25 +++ docs/PROGRESS.md | 19 +++ .../world/bentobox/tradewinds/Settings.java | 15 ++ .../world/bentobox/tradewinds/TradeWinds.java | 22 ++- .../tradewinds/galaxy/GalaxyConfig.java | 32 +++- .../tradewinds/galaxy/GalaxyEngine.java | 4 +- .../generator/ChunkGeneratorWorld.java | 2 +- .../tradewinds/generator/IslandDecorator.java | 147 +++++++++++++++++- .../tradewinds/generator/IslandPalette.java | 71 ++++++--- src/main/resources/config.yml | 9 ++ .../bentobox/tradewinds/SettingsTest.java | 4 + .../tradewinds/galaxy/GalaxyEngineTest.java | 15 ++ .../generator/ChunkGeneratorWorldTest.java | 2 +- .../generator/IslandDecoratorTest.java | 37 ++++- 14 files changed, 373 insertions(+), 31 deletions(-) diff --git a/TESTING.md b/TESTING.md index 85f93b7..3703175 100644 --- a/TESTING.md +++ b/TESTING.md @@ -93,3 +93,28 @@ in chunks generated by this build — visit islands you have NOT been to before no despawn). - [x] Restart the server and revisit: residents persist, and no duplicate set spawns. - [ ] Same seed regeneration: dock bearing, plaza position, and stall layout are identical. + +## Stage 2b — Island identity (type landmarks, dressing, configurable weights) + +Playtest feedback: islands looked the same apart from biome; INDUSTRIAL didn't read +industrial. **Needs freshly generated island chunks**, as ever. + +- [ ] Each island has a **type landmark** on the inland edge of the plaza (opposite the dock): + - INDUSTRIAL: brick chimney with a smoking signal fire on top (visible from the sea!), + blast furnaces, anvil, coal/iron block piles at the foot. + - MINING: timbered shaft head, rails, spoil heap, exposed ore blocks. + - AGRICULTURAL: fenced wheat plot with irrigation channel, hay-bale stack. + - FISHING: smokehouse campfire + barrel stack, and a moored rowboat at the pier end. + - FOREST: log pile with stripped logs. + - LUXURY: chiseled-quartz fountain with potted flowers. + - FROZEN: packed-ice beacon cairn with a lantern. +- [ ] **Plaza surface** varies by type: polished blackstone (INDUSTRIAL), smooth quartz + (LUXURY), cobblestone (MINING), podzol (FOREST), planks (FISHING/FROZEN), + dirt path (AGRICULTURAL). +- [ ] A **type-colored banner** and lantern post fly at the seaward end of every quay — + the island's "flag" as you approach by boat. +- [ ] **Workstations** beside each stall (blast furnace/grindstone/anvil on INDUSTRIAL, + lectern/brewing stand on LUXURY, composter/smoker/loom on AGRICULTURAL, …). +- [ ] Villagers do NOT lose or change their professions near the workstations (locked by XP). +- [ ] `galaxy.type-weights` appears in config.yml; setting a type's weight to 0 and + regenerating removes that type (needs world regen — weights are seed-shaping). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c464bb7..0b9ffed 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -33,6 +33,25 @@ Stage 0/1 checklists fully passed in-game before starting this. - In test code, fully-qualified `world.bentobox...` names inside methods are shadowed by CommonTestSetup's `protected World world` field - use imports. +**Stage 2b — island identity (same day, playtest feedback "islands all look +the same; INDUSTRIAL doesn't look industrial"):** +- Type **landmarks** built by IslandDecorator on the inland plaza edge: + INDUSTRIAL brick chimney with campfire-on-hay signal smoke (visible from + sea), MINING shaft head + rails + ore, AGRICULTURAL wheat plot + hay, + FISHING smokehouse + moored OakBoat at the pier, FOREST log pile, LUXURY + quartz fountain, FROZEN ice beacon. +- **Plaza surface per type** (polished blackstone / smooth quartz / + cobblestone / podzol / planks / dirt path) via IslandPalette; type-colored + **banner + lantern at the pier end**; **workstation blocks** beside each + stall (safe now professions are XP-locked). +- **`galaxy.type-weights` config**: weights moved into GalaxyConfig (record + gained a typeWeights param with a compact-constructor fallback to enum + defaults on zero/empty totals; old 8-arg constructor kept for + compatibility). Settings parses name→weight map, unknown names logged. + Weight changes reshape the galaxy → needsReset. +- 58 tests green (landmark signatures, pier banner, weight overrides, + default-weights galaxy unchanged by the new parameter). + **Playtest round 2 fixes (same day):** - **Dock gap:** the plaza blend ring outranked the dock strip in `columnPlanAt`, and on the seaward side the ring blends toward submerged diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index b6dacae..08b31da 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -95,6 +95,19 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.band-radius", needsReset = true) private int bandRadius = 5000; + @ConfigComment("Relative spawn weight per island type. Higher = more common; 0 disables a type.") + @ConfigComment("Types: AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN.") + @ConfigEntry(path = "galaxy.type-weights", needsReset = true) + private Map typeWeights = defaultTypeWeights(); + + private static Map defaultTypeWeights() { + Map map = new HashMap<>(); + for (world.bentobox.tradewinds.galaxy.IslandType t : world.bentobox.tradewinds.galaxy.IslandType.values()) { + map.put(t.name(), t.getWeight()); + } + return map; + } + /* TRAVEL */ @ConfigComment("Base warp fuel cost multiplier: fuel units per block of Euclidean route distance.") @ConfigComment("Per-edge overrides come later via the route-graph config.") @@ -2023,6 +2036,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setLandLift(int landLift) { this.landLift = landLift; } public int getBandRadius() { return bandRadius; } public void setBandRadius(int bandRadius) { this.bandRadius = bandRadius; } + public Map getTypeWeights() { return typeWeights; } + public void setTypeWeights(Map typeWeights) { this.typeWeights = typeWeights; } public double getFuelPerBlock() { return fuelPerBlock; } public void setFuelPerBlock(double fuelPerBlock) { this.fuelPerBlock = fuelPerBlock; } public double getWarpFailureChance() { return warpFailureChance; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index c5b60a8..8c4c2fa 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -1,5 +1,7 @@ package world.bentobox.tradewinds; +import java.util.EnumMap; +import java.util.Map; import java.util.Objects; import org.bukkit.World; @@ -26,6 +28,7 @@ import world.bentobox.tradewinds.listeners.IntersticePortalListener; import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandType; import world.bentobox.tradewinds.generator.ChunkGeneratorWorld; import world.bentobox.tradewinds.generator.GalaxyIslandRegistrar; import world.bentobox.tradewinds.generator.TradeWindsBiomeProvider; @@ -232,12 +235,29 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { long seed = s.getGalaxySeed() != 0 ? s.getGalaxySeed() : worldSeed; galaxyEngine = new GalaxyEngine(new GalaxyConfig(seed, s.getGalaxyMinSeparation(), s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), - s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight())); + s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight(), typeWeights())); log("TradeWinds galaxy seed: " + seed); } return galaxyEngine; } + /** + * Parse the configured island type weights; unknown type names are logged + * and skipped (a zero total falls back to built-in defaults inside + * GalaxyConfig). + */ + private Map typeWeights() { + Map weights = new EnumMap<>(IslandType.class); + getSettings().getTypeWeights().forEach((name, weight) -> { + try { + weights.put(IslandType.valueOf(name.toUpperCase(java.util.Locale.ENGLISH)), weight); + } catch (IllegalArgumentException e) { + logError("Unknown island type in galaxy.type-weights: " + name); + } + }); + return weights; + } + /** * @return the galaxy engine, or null if no world query has initialized it yet */ diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java index 86ae340..28de6c6 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -1,5 +1,10 @@ package world.bentobox.tradewinds.galaxy; +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + /** * Immutable configuration for the {@link GalaxyEngine}. Built from the addon * Settings by the caller so the galaxy package stays free of Bukkit and @@ -14,11 +19,36 @@ * @param starterMinIslands the guaranteed number of islands nearest spawn (density floor) * @param bandRadius distance from spawn per security-band step, in blocks * @param seaLevel the world's sea surface Y - dock and plaza heights hang off it + * @param typeWeights relative spawn weight per island type; a zero or missing + * total falls back to the built-in defaults * * @author tastybento */ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, - int starterMinIslands, int bandRadius, int seaLevel) { + int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights) { + + public GalaxyConfig { + if (typeWeights == null || typeWeights.values().stream().mapToInt(w -> Math.max(0, w)).sum() <= 0) { + typeWeights = defaultTypeWeights(); + } + } + + /** + * Convenience constructor using the built-in type weights. + */ + public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, + int starterMinIslands, int bandRadius, int seaLevel) { + this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, + defaultTypeWeights()); + } + + /** + * @return the built-in default weight for every island type + */ + public static Map defaultTypeWeights() { + return Arrays.stream(IslandType.values()) + .collect(Collectors.toUnmodifiableMap(Function.identity(), IslandType::getWeight)); + } /** * Grid cell size in blocks. With jitter confined to +/- minSeparation/2 of a diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index cf8c35f..55daf4e 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -132,11 +132,11 @@ private Optional computeIsland(int cellX, int cellZ) { private IslandType rollType(int cellX, int cellZ) { int total = 0; for (IslandType t : IslandType.values()) { - total += t.getWeight(); + total += Math.max(0, config.typeWeights().getOrDefault(t, 0)); } long roll = Math.floorMod(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_TYPE), total); for (IslandType t : IslandType.values()) { - roll -= t.getWeight(); + roll -= Math.max(0, config.typeWeights().getOrDefault(t, 0)); if (roll < 0) { return t; } diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index ae87e8a..b8c0905 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -151,7 +151,7 @@ private Material columnMaterial(FloorMats mats, int y, int floorTop, boolean lan return y == floorTop - 1 ? IslandPalette.planks(plan.island().type()) : Material.STONE_BRICKS; } if (plan != null && plan.feature() == ColumnPlan.Feature.PLAZA && plan.blend() >= 1.0 && y == floorTop - 1) { - return Material.DIRT_PATH; + return IslandPalette.plazaSurface(plan.island().type()); } if (!land) { return rand.nextBoolean() ? mats.top() : mats.base(); diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java index d70facf..a6698a0 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java @@ -10,6 +10,7 @@ import org.bukkit.World; import org.bukkit.entity.IronGolem; import org.bukkit.entity.Villager; +import org.bukkit.entity.boat.OakBoat; import org.bukkit.generator.BlockPopulator; import org.bukkit.generator.LimitedRegion; import org.bukkit.generator.WorldInfo; @@ -21,6 +22,7 @@ import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.Hashing; import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.IslandType; import world.bentobox.tradewinds.galaxy.SecurityBand; /** @@ -55,7 +57,7 @@ public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int c return; } GalaxyEngine engine = addon.getGalaxyEngine(worldInfo.getSeed()); - // Islands whose plaza center lands in this chunk + // Islands whose plaza center or pier end lands in this chunk int minX = chunkX << 4; int minZ = chunkZ << 4; for (IslandSpec spec : engine.islandsNear(minX + 8, minZ + 8, engine.getConfig().terrainRadius() + 16)) { @@ -64,6 +66,35 @@ public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int c && plan.plazaZ() < minZ + 16) { decoratePlaza(worldInfo, spec, plan, limitedRegion); } + int pierX = spec.centerX() + (int) Math.round(Math.cos(plan.bearing()) * (plan.dockEnd() - 2)); + int pierZ = spec.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * (plan.dockEnd() - 2)); + if (pierX >= minX && pierX < minX + 16 && pierZ >= minZ && pierZ < minZ + 16) { + decoratePierEnd(worldInfo, spec, pierX, pierZ, limitedRegion); + } + } + } + + /** + * Pier-end dressing: the island's banner flying at the seaward end of the + * quay (spot your destination's color from open water), a lantern, and on + * FISHING islands a moored rowboat. + */ + private void decoratePierEnd(WorldInfo worldInfo, IslandSpec spec, int pierX, int pierZ, LimitedRegion region) { + int deckY = addon.getGalaxyEngine(worldInfo.getSeed()).getConfig().seaLevel() + GalaxyEngine.DOCK_RISE; + setIfPossible(region, pierX, deckY + 1, pierZ, IslandPalette.banner(spec.type())); + // Lantern on a post one block to the side + int lx = pierX + (spec.cellX() % 2 == 0 ? 2 : -2); + setIfPossible(region, lx, deckY + 1, pierZ, IslandPalette.fence(spec.type())); + setIfPossible(region, lx, deckY + 2, pierZ, Material.LANTERN); + if (spec.type() == IslandType.FISHING) { + World world = Bukkit.getWorld(worldInfo.getUID()); + // Moored rowboat on the water beside the deck + Location loc = new Location(world, pierX + 0.5, deckY - GalaxyEngine.DOCK_RISE + 1.0, pierZ + 4.5); + if (region.isInRegion(loc)) { + OakBoat boat = region.createEntity(loc, OakBoat.class); + boat.setPersistent(true); + region.addEntity(boat); + } } } @@ -87,21 +118,133 @@ private void decoratePlaza(WorldInfo worldInfo, IslandSpec spec, DockPlan plan, setIfPossible(region, lx, y + 2, lz, Material.LANTERN); } - // Market stalls on a ring: fence corners, wool canopy, a barrel counter + // Market stalls on a ring: fence corners, wool canopy, a barrel counter, + // and a profession workstation beside each stall int stalls = 2 + rand.nextInt(3); double startAngle = rand.nextDouble() * Math.PI * 2; int stallR = (int) (plan.plazaRadius() * 0.55); + List workstations = IslandPalette.workstations(spec.type()); for (int i = 0; i < stalls; i++) { double angle = startAngle + i * (Math.PI * 2 / stalls); int sx = plan.plazaX() + (int) Math.round(Math.cos(angle) * stallR); int sz = plan.plazaZ() + (int) Math.round(Math.sin(angle) * stallR); buildStall(region, spec, sx, y, sz); + // Workstation just outside the stall, facing the ring center + int wx = plan.plazaX() + (int) Math.round(Math.cos(angle) * (stallR + 3)); + int wz = plan.plazaZ() + (int) Math.round(Math.sin(angle) * (stallR + 3)); + setIfPossible(region, wx, y, wz, workstations.get(i % workstations.size())); } + // The island's signature landmark on the inland edge of the plaza, + // opposite the dock - this is what makes an INDUSTRIAL island read + // industrial before you ever talk to a villager + int landmarkDist = plan.plazaRadius() - 5; + int lx = plan.plazaX() - (int) Math.round(Math.cos(plan.bearing()) * landmarkDist); + int lz = plan.plazaZ() - (int) Math.round(Math.sin(plan.bearing()) * landmarkDist); + buildLandmark(region, spec, rand, lx, y, lz); + World world = Bukkit.getWorld(worldInfo.getUID()); spawnResidents(spec, plan, region, rand, world, y); } + /** + * One signature structure per island type. Code-built and deterministic, + * like the stalls; replaceable by blueprint sets later. + */ + private void buildLandmark(LimitedRegion region, IslandSpec spec, Random rand, int x, int y, int z) { + switch (spec.type()) { + case INDUSTRIAL -> { + // Brick chimney with a signal fire on top: a smoke column visible + // from open water. Smelter yard at its foot. + for (int dy = 0; dy < 2; dy++) { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + setIfPossible(region, x + dx, y + dy, z + dz, Material.BRICKS); + } + } + } + for (int dy = 2; dy < 9; dy++) { + setIfPossible(region, x, y + dy, z, Material.BRICKS); + } + setIfPossible(region, x, y + 9, z, Material.HAY_BLOCK); + setIfPossible(region, x, y + 10, z, Material.CAMPFIRE); + setIfPossible(region, x + 2, y, z, Material.BLAST_FURNACE); + setIfPossible(region, x + 2, y, z + 1, Material.BLAST_FURNACE); + setIfPossible(region, x + 2, y, z - 1, Material.ANVIL); + setIfPossible(region, x - 2, y, z, Material.COAL_BLOCK); + setIfPossible(region, x - 2, y, z + 1, Material.IRON_BLOCK); + } + case MINING -> { + // Timbered shaft head with rails and a spoil heap + for (int dy = 0; dy < 3; dy++) { + setIfPossible(region, x - 1, y + dy, z, Material.STRIPPED_SPRUCE_LOG); + setIfPossible(region, x + 1, y + dy, z, Material.STRIPPED_SPRUCE_LOG); + } + setIfPossible(region, x, y + 3, z, Material.SPRUCE_PLANKS); + setIfPossible(region, x, y, z + 1, Material.RAIL); + setIfPossible(region, x, y, z + 2, Material.RAIL); + setIfPossible(region, x + 2, y, z + 1, Material.COBBLESTONE); + setIfPossible(region, x + 2, y, z + 2, Material.GRAVEL); + setIfPossible(region, x + 3, y, z + 1, Material.IRON_ORE); + setIfPossible(region, x - 2, y, z + 1, Material.COAL_ORE); + } + case AGRICULTURAL -> { + // Fenced wheat plot with an irrigation channel and hay stack + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + if (dx == 0) { + setIfPossible(region, x + dx, y - 1, z + dz, Material.WATER); + } else { + setIfPossible(region, x + dx, y - 1, z + dz, Material.FARMLAND); + setIfPossible(region, x + dx, y, z + dz, Material.WHEAT); + } + } + } + setIfPossible(region, x + 4, y, z, Material.HAY_BLOCK); + setIfPossible(region, x + 4, y + 1, z, Material.HAY_BLOCK); + setIfPossible(region, x + 4, y, z + 1, Material.HAY_BLOCK); + } + case FISHING -> { + // Smokehouse corner: campfire, barrel stack + setIfPossible(region, x, y, z, Material.CAMPFIRE); + setIfPossible(region, x + 1, y, z, Material.BARREL); + setIfPossible(region, x + 1, y + 1, z, Material.BARREL); + setIfPossible(region, x + 1, y, z + 1, Material.BARREL); + } + case FOREST -> { + // Log pile at the sawyer's yard + Material log = Material.DARK_OAK_LOG; + for (int dx = 0; dx < 3; dx++) { + for (int dz = 0; dz < 2; dz++) { + setIfPossible(region, x + dx, y, z + dz, log); + } + } + setIfPossible(region, x, y + 1, z, Material.STRIPPED_DARK_OAK_LOG); + setIfPossible(region, x + 1, y + 1, z, Material.STRIPPED_DARK_OAK_LOG); + } + case LUXURY -> { + // Quartz fountain + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + setIfPossible(region, x + dx, y, z + dz, + dx == 0 && dz == 0 ? Material.WATER : Material.CHISELED_QUARTZ_BLOCK); + } + } + setIfPossible(region, x + 1, y + 1, z + 1, Material.POTTED_PINK_TULIP); + setIfPossible(region, x - 1, y + 1, z - 1, Material.POTTED_BLUE_ORCHID); + } + case FROZEN -> { + // Ice beacon: packed-ice cairn with a lantern + for (int dy = 0; dy < 4; dy++) { + setIfPossible(region, x, y + dy, z, Material.PACKED_ICE); + } + setIfPossible(region, x, y + 4, z, Material.LANTERN); + setIfPossible(region, x + 1, y, z, Material.SNOW_BLOCK); + setIfPossible(region, x - 1, y, z + 1, Material.SNOW_BLOCK); + } + } + } + private long engineSeed(WorldInfo worldInfo) { return addon.getGalaxyEngine(worldInfo.getSeed()).getConfig().seed(); } diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java b/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java index c4073b3..57b4dd3 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java @@ -9,36 +9,57 @@ import world.bentobox.tradewinds.galaxy.IslandType; /** - * The Bukkit-material face of each island type: dock wood, stall canopy color, - * and resident villager professions. Kept out of the galaxy package so that - * stays Bukkit-free. + * The Bukkit-material face of each island type: dock wood, plaza surface, + * stall canopy color, pier banner, villager professions and their workstation + * blocks. Kept out of the galaxy package so that stays Bukkit-free. * * @author tastybento */ public final class IslandPalette { - private record Palette(Material planks, Material fence, Material canopy, - List professions) { + private record Palette(Material planks, Material fence, Material canopy, Material plazaSurface, Material banner, + List professions, List workstations) { } private static final Map PALETTES = Map.of( - IslandType.AGRICULTURAL, new Palette(Material.OAK_PLANKS, Material.OAK_FENCE, Material.YELLOW_WOOL, - List.of(Villager.Profession.FARMER, Villager.Profession.BUTCHER, Villager.Profession.SHEPHERD)), - IslandType.FOREST, new Palette(Material.DARK_OAK_PLANKS, Material.DARK_OAK_FENCE, Material.GREEN_WOOL, - List.of(Villager.Profession.FLETCHER, Villager.Profession.CARTOGRAPHER)), - IslandType.FISHING, new Palette(Material.OAK_PLANKS, Material.OAK_FENCE, Material.BLUE_WOOL, + IslandType.AGRICULTURAL, + new Palette(Material.OAK_PLANKS, Material.OAK_FENCE, Material.YELLOW_WOOL, Material.DIRT_PATH, + Material.YELLOW_BANNER, + List.of(Villager.Profession.FARMER, Villager.Profession.BUTCHER, Villager.Profession.SHEPHERD), + List.of(Material.COMPOSTER, Material.SMOKER, Material.LOOM)), + IslandType.FOREST, + new Palette(Material.DARK_OAK_PLANKS, Material.DARK_OAK_FENCE, Material.GREEN_WOOL, Material.PODZOL, + Material.GREEN_BANNER, + List.of(Villager.Profession.FLETCHER, Villager.Profession.CARTOGRAPHER), + List.of(Material.FLETCHING_TABLE, Material.CARTOGRAPHY_TABLE)), + IslandType.FISHING, + new Palette(Material.OAK_PLANKS, Material.OAK_FENCE, Material.BLUE_WOOL, Material.OAK_PLANKS, + Material.BLUE_BANNER, List.of(Villager.Profession.FISHERMAN, Villager.Profession.FISHERMAN, - Villager.Profession.LEATHERWORKER)), - IslandType.MINING, new Palette(Material.SPRUCE_PLANKS, Material.SPRUCE_FENCE, Material.GRAY_WOOL, - List.of(Villager.Profession.MASON, Villager.Profession.TOOLSMITH)), - IslandType.INDUSTRIAL, new Palette(Material.ACACIA_PLANKS, Material.ACACIA_FENCE, Material.ORANGE_WOOL, + Villager.Profession.LEATHERWORKER), + List.of(Material.BARREL, Material.CAULDRON, Material.SMOKER)), + IslandType.MINING, + new Palette(Material.SPRUCE_PLANKS, Material.SPRUCE_FENCE, Material.GRAY_WOOL, Material.COBBLESTONE, + Material.GRAY_BANNER, + List.of(Villager.Profession.MASON, Villager.Profession.TOOLSMITH), + List.of(Material.STONECUTTER, Material.SMITHING_TABLE)), + IslandType.INDUSTRIAL, + new Palette(Material.ACACIA_PLANKS, Material.ACACIA_FENCE, Material.ORANGE_WOOL, + Material.POLISHED_BLACKSTONE, Material.ORANGE_BANNER, List.of(Villager.Profession.TOOLSMITH, Villager.Profession.WEAPONSMITH, - Villager.Profession.ARMORER)), - IslandType.LUXURY, new Palette(Material.CHERRY_PLANKS, Material.CHERRY_FENCE, Material.MAGENTA_WOOL, + Villager.Profession.ARMORER), + List.of(Material.BLAST_FURNACE, Material.GRINDSTONE, Material.ANVIL)), + IslandType.LUXURY, + new Palette(Material.CHERRY_PLANKS, Material.CHERRY_FENCE, Material.MAGENTA_WOOL, Material.SMOOTH_QUARTZ, + Material.MAGENTA_BANNER, List.of(Villager.Profession.LIBRARIAN, Villager.Profession.CLERIC, - Villager.Profession.CARTOGRAPHER)), - IslandType.FROZEN, new Palette(Material.SPRUCE_PLANKS, Material.SPRUCE_FENCE, Material.LIGHT_BLUE_WOOL, - List.of(Villager.Profession.LEATHERWORKER, Villager.Profession.CLERIC))); + Villager.Profession.CARTOGRAPHER), + List.of(Material.LECTERN, Material.BREWING_STAND, Material.CARTOGRAPHY_TABLE)), + IslandType.FROZEN, + new Palette(Material.SPRUCE_PLANKS, Material.SPRUCE_FENCE, Material.LIGHT_BLUE_WOOL, + Material.SPRUCE_PLANKS, Material.LIGHT_BLUE_BANNER, + List.of(Villager.Profession.LEATHERWORKER, Villager.Profession.CLERIC), + List.of(Material.CAULDRON, Material.BREWING_STAND))); private IslandPalette() { // Static use only @@ -56,7 +77,19 @@ public static Material canopy(IslandType type) { return PALETTES.get(type).canopy(); } + public static Material plazaSurface(IslandType type) { + return PALETTES.get(type).plazaSurface(); + } + + public static Material banner(IslandType type) { + return PALETTES.get(type).banner(); + } + public static List professions(IslandType type) { return PALETTES.get(type).professions(); } + + public static List workstations(IslandType type) { + return PALETTES.get(type).workstations(); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ce06e35..a43826e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -29,6 +29,15 @@ galaxy: land-lift: 45 # Distance from spawn per security-band step (Safe -> Policed -> Frontier -> Lawless -> Anarchic). band-radius: 5000 + # Relative spawn weight per island type. Higher = more common; 0 disables a type. + type-weights: + AGRICULTURAL: 10 + FOREST: 10 + FISHING: 10 + MINING: 10 + INDUSTRIAL: 8 + LUXURY: 4 + FROZEN: 8 travel: warp: # Base warp fuel cost multiplier: fuel units per block of Euclidean route distance. diff --git a/src/test/java/world/bentobox/tradewinds/SettingsTest.java b/src/test/java/world/bentobox/tradewinds/SettingsTest.java index 4d7aa2a..8c9644f 100644 --- a/src/test/java/world/bentobox/tradewinds/SettingsTest.java +++ b/src/test/java/world/bentobox/tradewinds/SettingsTest.java @@ -79,6 +79,10 @@ void testGalaxyDefaults() { assertEquals(5000, settings.getBandRadius()); // Terrain must fit well inside the protection range assertTrue(settings.getIslandTerrainRadius() < settings.getIslandProtectionRange()); + // Type weights default to the enum weights, keyed by name + assertEquals(7, settings.getTypeWeights().size()); + assertEquals(10, settings.getTypeWeights().get("AGRICULTURAL")); + assertEquals(4, settings.getTypeWeights().get("LUXURY")); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index a159dce..2d51fb3 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -198,6 +198,21 @@ void testWalkwayIsContinuousFromPlazaToPierEnd() { } } + @Test + void testTypeWeightOverrides() { + // Only LUXURY weighted -> every island is LUXURY + GalaxyConfig cfg = new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 5, 5000, 70, + java.util.Map.of(IslandType.LUXURY, 1)); + islands(new GalaxyEngine(cfg), 5).forEach(s -> assertEquals(IslandType.LUXURY, s.type())); + // A zero/empty weight table falls back to the built-in defaults + GalaxyConfig broken = new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 5, 5000, 70, java.util.Map.of()); + assertEquals(GalaxyConfig.defaultTypeWeights(), broken.typeWeights()); + // And the default-weights galaxy is unchanged by the new parameter + assertEquals(islands(new GalaxyEngine(config(SEED, 1.0)), 5), + islands(new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 5, 5000, 70, + GalaxyConfig.defaultTypeWeights())), 5)); + } + @Test void testNamesAreDistinctEnough() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 999373e..c773805 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -228,7 +228,7 @@ void testPlazaAndDockTerraform() { int px = plan.plazaX() & 15; int pz = plan.plazaZ() & 15; int plazaSurface = settings.getSeaHeight() + GalaxyEngine.PLAZA_RISE; - assertEquals(Material.DIRT_PATH, plaza.get(px, plazaSurface, pz)); + assertEquals(IslandPalette.plazaSurface(spec.type()), plaza.get(px, plazaSurface, pz)); assertEquals(Material.AIR, plaza.get(px, plazaSurface + 1, pz)); // A point on the quay: plank deck at seaHeight + DOCK_RISE over stone bricks, no water above diff --git a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java index 3621da3..7793a46 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java @@ -33,6 +33,7 @@ import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.IslandType; import world.bentobox.tradewinds.galaxy.SecurityBand; /** @@ -82,6 +83,7 @@ private LimitedRegion region() { placed.add(inv.getArgument(3)); return null; }).when(region).setType(anyInt(), anyInt(), anyInt(), any(Material.class)); + when(region.isInRegion(any(Location.class))).thenReturn(true); when(region.createEntity(any(Location.class), any())).thenAnswer(inv -> { Class clazz = inv.getArgument(1); if (clazz == Villager.class) { @@ -90,10 +92,13 @@ private LimitedRegion region() { villagers.add(villager); return villager; } - IronGolem golem = mock(IronGolem.class); - when(golem.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); - golems.add(golem); - return golem; + if (clazz == IronGolem.class) { + IronGolem golem = mock(IronGolem.class); + when(golem.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); + golems.add(golem); + return golem; + } + return mock(clazz); }); return region; } @@ -111,6 +116,10 @@ void testPlazaChunkIsDecorated() { // Residents assertFalse(villagers.isEmpty(), "No villagers spawned"); assertEquals(IslandDecorator.golemCount(spec.band()), golems.size()); + // Workstations and the type landmark are present + assertTrue(placed.contains(IslandPalette.workstations(spec.type()).get(0)), "No workstation placed"); + assertTrue(placed.stream().anyMatch(LANDMARK_SIGNATURES.get(spec.type())::contains), + "No landmark signature block for " + spec.type()); // Professions come from the island type's list verify(villagers.get(0)).setProfession(IslandPalette.professions(spec.type()).get(0)); verify(villagers.get(0)).setPersistent(true); @@ -152,6 +161,26 @@ void testIntersticeNeverDecorated() { verify(region, never()).setType(anyInt(), anyInt(), anyInt(), any(Material.class)); } + /** One block that only the landmark of that type places. */ + private static final java.util.Map> LANDMARK_SIGNATURES = java.util.Map.of( + IslandType.AGRICULTURAL, List.of(Material.WHEAT, Material.HAY_BLOCK), + IslandType.FOREST, List.of(Material.DARK_OAK_LOG), + IslandType.FISHING, List.of(Material.CAMPFIRE), + IslandType.MINING, List.of(Material.RAIL, Material.STRIPPED_SPRUCE_LOG), + IslandType.INDUSTRIAL, List.of(Material.BRICKS, Material.CAMPFIRE), + IslandType.LUXURY, List.of(Material.CHISELED_QUARTZ_BLOCK), + IslandType.FROZEN, List.of(Material.PACKED_ICE)); + + @Test + void testPierEndGetsBanner() { + LimitedRegion region = region(); + int pierX = spec.centerX() + (int) Math.round(Math.cos(plan.bearing()) * (plan.dockEnd() - 2)); + int pierZ = spec.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * (plan.dockEnd() - 2)); + decorator.populate(worldInfo(Environment.NORMAL), new Random(1), pierX >> 4, pierZ >> 4, region); + assertTrue(placed.contains(IslandPalette.banner(spec.type())), "No banner at pier end"); + assertTrue(placed.contains(Material.LANTERN), "No lantern at pier end"); + } + @Test void testGolemCountsByBand() { assertEquals(3, IslandDecorator.golemCount(SecurityBand.SAFE)); From 9215ef9313e2755dd2c2b3bc161f609f269163c2 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 18:12:53 -0700 Subject: [PATCH 007/112] =?UTF-8?q?Stage=203:=20travel=20=E2=80=94=20warp?= =?UTF-8?q?=20dialog,=20route=20graph,=20fuel,=20charting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Paper 26.2 dialog API verified and used for the warp dialog (multiAction buttons with customClick callbacks) - RouteGraph: seeded Euclidean costs, per-edge overrides, arrival on the origin bearing just inside the destination border - TWPlayerData/PlayerDataManager: persistent charts, starter cluster pre-charted; ChartingListener charts islands on entering their range - FuelService: hold-only fuel (chest boat + bundles), cheapest-first burn, lava bucket leaves its bucket - WarpService: cancellable TWWarpEvent, dismount/teleport/re-seat, warp sickness effects; BorderPromptListener auto-offers at borders; /tw warp and /tw chart - 75 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 30 +++ docs/PROGRESS.md | 41 ++++ .../world/bentobox/tradewinds/Settings.java | 73 +++++++ .../world/bentobox/tradewinds/TradeWinds.java | 41 +++- .../api/events/IslandChartedEvent.java | 44 +++++ .../api/events/TWWarpCompletedEvent.java | 54 ++++++ .../tradewinds/api/events/TWWarpEvent.java | 67 +++++++ .../tradewinds/commands/TWChartCommand.java | 55 ++++++ .../tradewinds/commands/TWWarpCommand.java | 56 ++++++ .../dataobjects/PlayerDataManager.java | 81 ++++++++ .../tradewinds/dataobjects/TWPlayerData.java | 78 ++++++++ .../tradewinds/galaxy/RouteGraph.java | 72 +++++++ .../travel/BorderPromptListener.java | 102 ++++++++++ .../tradewinds/travel/ChartingListener.java | 70 +++++++ .../tradewinds/travel/FuelService.java | 155 +++++++++++++++ .../tradewinds/travel/WarpService.java | 180 ++++++++++++++++++ src/main/resources/config.yml | 32 ++++ src/main/resources/locales/en-US.yml | 15 ++ .../dataobjects/TWPlayerDataTest.java | 34 ++++ .../tradewinds/galaxy/RouteGraphTest.java | 59 ++++++ .../travel/BorderPromptListenerTest.java | 67 +++++++ .../tradewinds/travel/FuelServiceTest.java | 105 ++++++++++ .../tradewinds/travel/WarpSelectionTest.java | 110 +++++++++++ 23 files changed, 1620 insertions(+), 1 deletion(-) create mode 100644 src/main/java/world/bentobox/tradewinds/api/events/IslandChartedEvent.java create mode 100644 src/main/java/world/bentobox/tradewinds/api/events/TWWarpCompletedEvent.java create mode 100644 src/main/java/world/bentobox/tradewinds/api/events/TWWarpEvent.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWWarpCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java create mode 100644 src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/RouteGraph.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/FuelService.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/WarpService.java create mode 100644 src/test/java/world/bentobox/tradewinds/dataobjects/TWPlayerDataTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/galaxy/RouteGraphTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/FuelServiceTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java diff --git a/TESTING.md b/TESTING.md index 3703175..b12c678 100644 --- a/TESTING.md +++ b/TESTING.md @@ -118,3 +118,33 @@ industrial. **Needs freshly generated island chunks**, as ever. - [ ] Villagers do NOT lose or change their professions near the workstations (locked by XP). - [ ] `galaxy.type-weights` appears in config.yml; setting a type's weight to 0 and regenerating removes that type (needs world regen — weights are seed-shaping). + +## Stage 3 — Travel: warp, fuel, charting + +Get a chest boat, put fuel in it (coal is 8 units, a coal block 80, logs 1, +a lava bucket 100 — see `travel.fuel-values`), and: + +- [ ] **Charting**: joining fresh, `/tw chart` already lists the starter-cluster islands + (pre-charted). Rowing into a NEW island's waters (range 1000) pops "Charted !" + on the action bar, and it appears in `/tw chart` with type/band/distance. +- [ ] **Warp offer**: rowing a boat to within ~30 blocks of an island's border pops the + warp dialog automatically (once per 30s per island). `/tw warp` opens it anywhere + inside an island's waters while boated. +- [ ] The dialog lists **charted islands only** (uncharted never appear), nearest first, + with fuel cost per destination; the body shows fuel aboard. Unaffordable entries + are dark grey and clicking them just says "not enough fuel". +- [ ] **Warp**: clicking an affordable destination consumes fuel from the chest boat / + bundles (check the inventory after), plays portal particles/sound, dismounts, + teleports player AND boat, re-seats the player in the boat, applies ~8s nausea + + 3s blindness + 1 heart damage, and lands **just inside the destination's border on + the side facing the origin island** (~950 blocks from its center). +- [ ] Fuel in the player's pockets (not in chest boat or bundle) does NOT count and is + never consumed — hold-only is the spec's core rule. +- [ ] Lava bucket burns to an empty bucket (bucket stays aboard). +- [ ] Fuel costs match distance × 0.01 rounded up (check two islands at a known distance + via `/twadmin islands`); add an entry under `travel.warp.edge-overrides` + (e.g. `"0,0>0,1": 1`), reload, and see that edge cost change. +- [ ] Warp back: the full row-out → warp → warp-back loop works, including re-seating. +- [ ] Chart persists across relog and server restart. +- [ ] A player without a boat: `/tw warp` refuses ("aboard a boat"); the border prompt + does not fire while swimming. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 0b9ffed..24207ab 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,47 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 3 — Travel: warp, fuel, charting (2026-07-29) — CODE COMPLETE, awaiting in-game test + +**Verify-first resolved:** Paper 26.2 dialog API confirmed and used: +`Dialog.create(factory -> factory.empty().base(...).type(...))`, +`DialogType.multiAction(buttons)`, `ActionButton.create(label, tooltip, width, +DialogAction.customClick(callback, options))`, shown via Adventure's +`Player#showDialog`. No command round-trips - button callbacks call the warp +directly. + +**Done:** +- `RouteGraph` (pure): cost = Euclidean x fuel-per-block, ceil, min 1; + direction-independent `edgeKey("cx,cz>cx,cz")` overrides from config + (`travel.warp.edge-overrides`) make cheap lanes/expensive frontiers. + `arrivalPoint`: just inside the destination border on the origin bearing. +- `TWPlayerData` + `PlayerDataManager` (first Database use; AOneBlock + cache-in-front pattern): chart as Set of cell keys; new players pre-chart + the starter cluster; save on quit/disable. +- `ChartingListener`: chunk-crossing-gated moves chart islands whose range + (1000) the player enters - action bar + `IslandChartedEvent`. +- `FuelService`: hold = chest-boat inventory + bundle contents ONLY (spec + principle 1 - pocket fuel never counts). Cheapest-first consumption, + overshoot burned, lava bucket leaves its empty bucket. Values config + `travel.fuel-values`. +- `WarpService`: destination selection (charted-only, origin excluded, + nearest-first, capped, affordability-flagged - pure and tested separately + from dialog rendering); warp = TWWarpEvent (cancellable) -> consume -> + dismount -> teleportAsync player + boat -> re-seat next tick (AcidIsland + pattern) -> nausea/blindness/damage + portal effects -> + TWWarpCompletedEvent. Failure roll deliberately absent until Stage 5. +- `BorderPromptListener`: boat within trigger-distance (30) inside an island + border auto-offers the dialog, per-island cooldown. `/tw warp` (boated, in + island waters) and `/tw chart` commands. +- Tests: 75 green (route math incl. overrides + arrival geometry, chart data, + fuel hold-only/cheapest-first/lava rule, destination selection, border ring + geometry). + +**Notes:** +- Real `ItemStack`s work fine in tests under the mocked ItemFactory as long + as no ItemMeta operations are exercised (bundle internals are in-game-only + territory). + ## Stage 2 — Island content (2026-07-29) — CODE COMPLETE, awaiting in-game test Stage 0/1 checklists fully passed in-game before starting this. diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 08b31da..5867125 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -119,6 +119,61 @@ private static Map defaultTypeWeights() { @ConfigEntry(path = "travel.warp.failure-chance") private double warpFailureChance = 0.05; + @ConfigComment("Distance inside an island's border at which a boated player is offered the warp dialog.") + @ConfigEntry(path = "travel.warp.trigger-distance") + private int warpTriggerDistance = 30; + + @ConfigComment("How far inside the destination border a warp arrival lands.") + @ConfigEntry(path = "travel.warp.arrival-margin") + private int warpArrivalMargin = 50; + + @ConfigComment("Seconds between automatic warp-dialog offers at the same island's border.") + @ConfigEntry(path = "travel.warp.prompt-cooldown-seconds") + private int warpPromptCooldownSeconds = 30; + + @ConfigComment("Maximum destinations listed in the warp dialog (nearest first).") + @ConfigEntry(path = "travel.warp.max-destinations") + private int maxWarpDestinations = 20; + + @ConfigComment("Seconds of nausea after a warp. Warping hurts - it gates the under-equipped.") + @ConfigEntry(path = "travel.warp.nausea-seconds") + private int warpNauseaSeconds = 8; + + @ConfigComment("Seconds of blindness after a warp.") + @ConfigEntry(path = "travel.warp.blindness-seconds") + private int warpBlindnessSeconds = 3; + + @ConfigComment("Damage (in half-hearts) taken on warp arrival. 0 disables.") + @ConfigEntry(path = "travel.warp.damage") + private double warpDamage = 2.0; + + @ConfigComment("Fuel unit value per material. Fuel is consumed from the hold only:") + @ConfigComment("the chest boat's inventory and trading bundles - never loose pockets.") + @ConfigComment("Lava buckets leave their empty bucket behind. Non-stackable but potent: deliberate tension.") + @ConfigEntry(path = "travel.fuel-values") + private Map fuelValues = defaultFuelValues(); + + 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")) { + map.put(log, 1.0); + } + map.put("COAL", 8.0); + map.put("CHARCOAL", 8.0); + map.put("COAL_BLOCK", 80.0); + map.put("BLAZE_ROD", 12.0); + map.put("DRIED_KELP_BLOCK", 4.0); + map.put("LAVA_BUCKET", 100.0); + return map; + } + + @ConfigComment("Per-edge fuel cost overrides, replacing the distance-based cost in both directions.") + @ConfigComment("Key: the two island cells, smaller first, e.g. '0,0>1,2'. Value: absolute fuel units.") + @ConfigComment("Cheap lanes and expensive frontiers without regenerating the world.") + @ConfigEntry(path = "travel.warp.edge-overrides") + private Map edgeOverrides = new HashMap<>(); + /* ILLEGAL TRADE */ @ConfigComment("Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling.") @ConfigComment("Set false for family-friendly servers - removes the entire crime layer cleanly.") @@ -2042,6 +2097,24 @@ public void setConcurrentIslands(int concurrentIslands) { public void setFuelPerBlock(double fuelPerBlock) { this.fuelPerBlock = fuelPerBlock; } public double getWarpFailureChance() { return warpFailureChance; } public void setWarpFailureChance(double warpFailureChance) { this.warpFailureChance = warpFailureChance; } + public int getWarpTriggerDistance() { return warpTriggerDistance; } + public void setWarpTriggerDistance(int warpTriggerDistance) { this.warpTriggerDistance = warpTriggerDistance; } + public int getWarpArrivalMargin() { return warpArrivalMargin; } + public void setWarpArrivalMargin(int warpArrivalMargin) { this.warpArrivalMargin = warpArrivalMargin; } + public int getWarpPromptCooldownSeconds() { return warpPromptCooldownSeconds; } + public void setWarpPromptCooldownSeconds(int warpPromptCooldownSeconds) { this.warpPromptCooldownSeconds = warpPromptCooldownSeconds; } + public int getMaxWarpDestinations() { return maxWarpDestinations; } + public void setMaxWarpDestinations(int maxWarpDestinations) { this.maxWarpDestinations = maxWarpDestinations; } + public int getWarpNauseaSeconds() { return warpNauseaSeconds; } + public void setWarpNauseaSeconds(int warpNauseaSeconds) { this.warpNauseaSeconds = warpNauseaSeconds; } + public int getWarpBlindnessSeconds() { return warpBlindnessSeconds; } + public void setWarpBlindnessSeconds(int warpBlindnessSeconds) { this.warpBlindnessSeconds = warpBlindnessSeconds; } + public double getWarpDamage() { return warpDamage; } + public void setWarpDamage(double warpDamage) { this.warpDamage = warpDamage; } + public Map getFuelValues() { return fuelValues; } + public void setFuelValues(Map fuelValues) { this.fuelValues = fuelValues; } + public Map getEdgeOverrides() { return edgeOverrides; } + public void setEdgeOverrides(Map edgeOverrides) { this.edgeOverrides = edgeOverrides; } public boolean isIllegalTradeEnabled() { return illegalTradeEnabled; } public void setIllegalTradeEnabled(boolean illegalTradeEnabled) { this.illegalTradeEnabled = illegalTradeEnabled; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 8c4c2fa..56980f2 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -24,8 +24,16 @@ import world.bentobox.bentobox.lists.Flags; import world.bentobox.tradewinds.commands.AdminIslandsCommand; import world.bentobox.tradewinds.commands.AdminTpIslandCommand; +import world.bentobox.tradewinds.commands.TWChartCommand; import world.bentobox.tradewinds.commands.TWSpawnCommand; +import world.bentobox.tradewinds.commands.TWWarpCommand; +import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.galaxy.RouteGraph; import world.bentobox.tradewinds.listeners.IntersticePortalListener; +import world.bentobox.tradewinds.travel.BorderPromptListener; +import world.bentobox.tradewinds.travel.ChartingListener; +import world.bentobox.tradewinds.travel.FuelService; +import world.bentobox.tradewinds.travel.WarpService; import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandType; @@ -52,6 +60,10 @@ public class TradeWinds extends GameModeAddon { private final Config configObject = new Config<>(this, Settings.class); private BiomeProvider biomeProvider; private @Nullable GalaxyEngine galaxyEngine; + private PlayerDataManager playerDataManager; + private FuelService fuelService; + private WarpService warpService; + private RouteGraph routeGraph; /** * This addon uses the new chunk generation API for the sea bottom @@ -84,6 +96,8 @@ public void setup() { setOnlyPlayer(true); setPermission("island"); new TWSpawnCommand(this); + new TWWarpCommand(this); + new TWChartCommand(this); new IslandInfoCommand(this); new IslandLanguageCommand(this); } @@ -125,6 +139,13 @@ public void onEnable() { registerListener(new GalaxyIslandRegistrar(this)); // Seal both worlds against portals - the interstice is warp-failure-only registerListener(new IntersticePortalListener(this)); + // Travel: charting, fuel, warp + playerDataManager = new PlayerDataManager(this); + fuelService = new FuelService(this); + routeGraph = new RouteGraph(getSettings().getFuelPerBlock(), getSettings().getEdgeOverrides()); + warpService = new WarpService(this); + registerListener(new ChartingListener(this)); + registerListener(new BorderPromptListener(this)); // Deterministic ocean spawn on the sea surface at the galaxy origin if (islandWorld != null) { islandWorld.setSpawnLocation(0, getSettings().getSeaHeight() + 1, 0); @@ -133,7 +154,9 @@ public void onEnable() { @Override public void onDisable() { - // Nothing to do here yet + if (playerDataManager != null) { + playerDataManager.saveAll(); + } } @NonNull @@ -221,6 +244,22 @@ public BiomeProvider getBiomeProvider() { return this.biomeProvider; } + public PlayerDataManager getPlayerDataManager() { + return playerDataManager; + } + + public FuelService getFuelService() { + return fuelService; + } + + public WarpService getWarpService() { + return warpService; + } + + public RouteGraph getRouteGraph() { + return routeGraph; + } + /** * The seeded galaxy for the overworld. Created on first use because the * effective seed may be the world's own seed (config galaxy.seed = 0), which diff --git a/src/main/java/world/bentobox/tradewinds/api/events/IslandChartedEvent.java b/src/main/java/world/bentobox/tradewinds/api/events/IslandChartedEvent.java new file mode 100644 index 0000000..40d49a1 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/api/events/IslandChartedEvent.java @@ -0,0 +1,44 @@ +package world.bentobox.tradewinds.api.events; + +import java.util.UUID; + +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Fired when a player charts a trading island (enters its range for the first + * time, or gains it another way). Ecosystem hook. + * + * @author tastybento + */ +public class IslandChartedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final UUID playerId; + private final IslandSpec island; + + public IslandChartedEvent(UUID playerId, IslandSpec island) { + this.playerId = playerId; + this.island = island; + } + + public UUID getPlayerId() { + return playerId; + } + + public IslandSpec getIsland() { + return island; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/api/events/TWWarpCompletedEvent.java b/src/main/java/world/bentobox/tradewinds/api/events/TWWarpCompletedEvent.java new file mode 100644 index 0000000..7b4f82a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/api/events/TWWarpCompletedEvent.java @@ -0,0 +1,54 @@ +package world.bentobox.tradewinds.api.events; + +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Fired after a warp has delivered the player (fuel consumed, teleport done). + * + * @author tastybento + */ +public class TWWarpCompletedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final IslandSpec from; + private final IslandSpec to; + private final int fuelSpent; + + public TWWarpCompletedEvent(Player player, IslandSpec from, IslandSpec to, int fuelSpent) { + this.player = player; + this.from = from; + this.to = to; + this.fuelSpent = fuelSpent; + } + + public Player getPlayer() { + return player; + } + + public IslandSpec getFrom() { + return from; + } + + public IslandSpec getTo() { + return to; + } + + public int getFuelSpent() { + return fuelSpent; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/api/events/TWWarpEvent.java b/src/main/java/world/bentobox/tradewinds/api/events/TWWarpEvent.java new file mode 100644 index 0000000..2ab4dd3 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/api/events/TWWarpEvent.java @@ -0,0 +1,67 @@ +package world.bentobox.tradewinds.api.events; + +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Fired before a warp executes (after fuel is confirmed but before it is + * consumed). Cancel to veto the warp at no cost to the player. + * + * @author tastybento + */ +public class TWWarpEvent extends Event implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final IslandSpec from; + private final IslandSpec to; + private final int fuelCost; + private boolean cancelled; + + public TWWarpEvent(Player player, IslandSpec from, IslandSpec to, int fuelCost) { + this.player = player; + this.from = from; + this.to = to; + this.fuelCost = fuelCost; + } + + public Player getPlayer() { + return player; + } + + public IslandSpec getFrom() { + return from; + } + + public IslandSpec getTo() { + return to; + } + + public int getFuelCost() { + return fuelCost; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java new file mode 100644 index 0000000..ddfeee2 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -0,0 +1,55 @@ +package world.bentobox.tradewinds.commands; + +import java.util.Comparator; +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Shows the player's chart: every island they have discovered, nearest first. + * + * @author tastybento + */ +public class TWChartCommand extends CompositeCommand { + + public TWChartCommand(CompositeCommand parent) { + super(parent, "chart"); + } + + @Override + public void setup() { + setPermission("island.chart"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.chart.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + int x = getWorld().equals(user.getWorld()) ? user.getLocation().getBlockX() : 0; + int z = getWorld().equals(user.getWorld()) ? user.getLocation().getBlockZ() : 0; + List charted = addon.getPlayerDataManager().get(user.getUniqueId()).getChartedIslands().stream() + .map(key -> key.split(",")) + .map(cell -> engine.islandInCell(Integer.parseInt(cell[0]), Integer.parseInt(cell[1]))) + .flatMap(java.util.Optional::stream) + .sorted(Comparator.comparingLong(spec -> spec.distanceSquared(x, z))) + .toList(); + if (charted.isEmpty()) { + user.sendMessage("tradewinds.chart.empty"); + return true; + } + user.sendMessage("tradewinds.chart.header", TextVariables.NUMBER, String.valueOf(charted.size())); + charted.forEach(spec -> user.sendMessage("tradewinds.chart.entry", + TextVariables.NAME, spec.name(), + "[type]", spec.type().name(), + "[band]", spec.band().getDisplayName(), + "[distance]", String.valueOf((int) Math.sqrt(spec.distanceSquared(x, z))))); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWWarpCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWWarpCommand.java new file mode 100644 index 0000000..2c65ad0 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWWarpCommand.java @@ -0,0 +1,56 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; +import java.util.Optional; + +import org.bukkit.entity.Boat; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Opens the warp dialog. Requires being in a boat inside an island's space - + * the warp engages from island borders, and this command just saves rowing the + * last few blocks to the line. + * + * @author tastybento + */ +public class TWWarpCommand extends CompositeCommand { + + public TWWarpCommand(CompositeCommand parent) { + super(parent, "warp"); + } + + @Override + public void setup() { + setPermission("island.warp"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.warp.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + if (!(user.getPlayer().getVehicle() instanceof Boat)) { + user.sendMessage("tradewinds.warp.not-in-boat"); + return false; + } + if (!getWorld().equals(user.getWorld())) { + user.sendMessage("general.errors.wrong-world"); + return false; + } + int x = user.getLocation().getBlockX(); + int z = user.getLocation().getBlockZ(); + int range = addon.getSettings().getIslandDistance(); + Optional origin = addon.getGalaxyEngine(getWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(spec -> spec.distanceSquared(x, z) <= (long) range * range).findFirst(); + if (origin.isEmpty()) { + user.sendMessage("tradewinds.warp.not-at-island"); + return false; + } + addon.getWarpService().openDialog(user.getPlayer(), origin.get()); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java b/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java new file mode 100644 index 0000000..ba23fc1 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java @@ -0,0 +1,81 @@ +package world.bentobox.tradewinds.dataobjects; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import world.bentobox.bentobox.database.Database; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; + +/** + * Cache-in-front-of-Database manager for {@link TWPlayerData} (AOneBlock + * pattern). New players start with the spawn starter cluster pre-charted so + * the warp dialog is never empty. + * + * @author tastybento + */ +public class PlayerDataManager { + + private final TradeWinds addon; + private final Database handler; + private final Map cache = new ConcurrentHashMap<>(); + + public PlayerDataManager(TradeWinds addon) { + this.addon = addon; + this.handler = new Database<>(addon, TWPlayerData.class); + } + + /** + * Get (loading or creating as needed) a player's data. + */ + public TWPlayerData get(UUID playerId) { + return cache.computeIfAbsent(playerId, id -> { + String key = id.toString(); + if (handler.objectExists(key)) { + TWPlayerData loaded = handler.loadObject(key); + if (loaded != null) { + return loaded; + } + } + TWPlayerData fresh = new TWPlayerData(key); + chartStarterCluster(fresh); + handler.saveObjectAsync(fresh); + return fresh; + }); + } + + /** + * Pre-chart the guaranteed starter islands nearest spawn. + */ + private void chartStarterCluster(TWPlayerData data) { + if (addon.getOverWorld() == null) { + return; + } + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + // The starter cells are the nearest guaranteed islands; chart everything + // within the starter cluster radius for good measure + engine.islandsNear(0, 0, addon.getSettings().getStarterClusterRadius() * 2).forEach(data::chart); + } + + public void save(UUID playerId) { + TWPlayerData data = cache.get(playerId); + if (data != null) { + handler.saveObjectAsync(data); + } + } + + public void saveAll() { + cache.values().forEach(handler::saveObjectAsync); + } + + /** + * Save and drop a player from the cache (call on quit). + */ + public void unload(UUID playerId) { + TWPlayerData data = cache.remove(playerId); + if (data != null) { + handler.saveObjectAsync(data); + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java new file mode 100644 index 0000000..0703d4b --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -0,0 +1,78 @@ +package world.bentobox.tradewinds.dataobjects; + +import java.util.HashSet; +import java.util.Set; + +import com.google.gson.annotations.Expose; + +import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.database.objects.Table; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Per-player TradeWinds state, keyed by player UUID. Stage 3 holds the chart; + * later stages add reputation, bounty, flags and cooldowns (spec §9). + * + * @author tastybento + */ +@Table(name = "TWPlayerData") +public class TWPlayerData implements DataObject { + + @Expose + private String uniqueId; + + /** + * Charted island cell keys ("cellX,cellZ"). Only charted islands appear in + * the warp dialog. + */ + @Expose + private Set chartedIslands = new HashSet<>(); + + public TWPlayerData() { + // Required by the database + } + + public TWPlayerData(String uniqueId) { + this.uniqueId = uniqueId; + } + + /** + * The chart key for an island. + */ + public static String chartKey(IslandSpec spec) { + return spec.cellX() + "," + spec.cellZ(); + } + + /** + * @return true if the island is on this player's chart + */ + public boolean isCharted(IslandSpec spec) { + return chartedIslands.contains(chartKey(spec)); + } + + /** + * Add an island to the chart. + * @return true if it was newly charted + */ + public boolean chart(IslandSpec spec) { + return chartedIslands.add(chartKey(spec)); + } + + @Override + public String getUniqueId() { + return uniqueId; + } + + @Override + public void setUniqueId(String uniqueId) { + this.uniqueId = uniqueId; + } + + public Set getChartedIslands() { + return chartedIslands; + } + + public void setChartedIslands(Set chartedIslands) { + this.chartedIslands = chartedIslands; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/RouteGraph.java b/src/main/java/world/bentobox/tradewinds/galaxy/RouteGraph.java new file mode 100644 index 0000000..5c0076a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/RouteGraph.java @@ -0,0 +1,72 @@ +package world.bentobox.tradewinds.galaxy; + +import java.util.Map; + +/** + * Warp route costs between islands. Default cost is Euclidean distance times + * the fuel-per-block multiplier; per-edge overrides (config) create cheap + * "warp lanes" or expensive frontiers without regenerating the world - the + * lane is the in-fiction excuse for any cost/distance mismatch. Pure math, no + * Bukkit. + * + * @author tastybento + */ +public class RouteGraph { + + private final double fuelPerBlock; + private final Map edgeOverrides; + + /** + * @param fuelPerBlock fuel units per block of route distance + * @param edgeOverrides absolute fuel cost per edge key (see {@link #edgeKey}), + * applied in both directions + */ + public RouteGraph(double fuelPerBlock, Map edgeOverrides) { + this.fuelPerBlock = fuelPerBlock; + this.edgeOverrides = Map.copyOf(edgeOverrides); + } + + /** + * Canonical config key for an edge between two islands, direction-independent: + * the lexicographically smaller "cellX,cellZ" first, joined by '>'. + */ + public static String edgeKey(IslandSpec a, IslandSpec b) { + String ka = a.cellX() + "," + a.cellZ(); + String kb = b.cellX() + "," + b.cellZ(); + return ka.compareTo(kb) <= 0 ? ka + ">" + kb : kb + ">" + ka; + } + + /** + * Fuel cost of warping between two islands, in whole fuel units (rounded up, + * minimum 1). + * + * @param from origin island + * @param to destination island + * @return fuel units + */ + public int cost(IslandSpec from, IslandSpec to) { + Double override = edgeOverrides.get(edgeKey(from, to)); + double cost = override != null ? override + : Math.sqrt(from.distanceSquared(to.centerX(), to.centerZ())) * fuelPerBlock; + return Math.max(1, (int) Math.ceil(cost)); + } + + /** + * Warp arrival point: just inside the destination's border, on the bearing + * of the origin island - you arrive on the side you notionally came from. + * + * @param from origin island + * @param to destination island + * @param islandRange the island space radius (border distance) + * @param margin how far inside the border to arrive + * @return {x, z} block position + */ + public static int[] arrivalPoint(IslandSpec from, IslandSpec to, int islandRange, int margin) { + double dx = (double) from.centerX() - to.centerX(); + double dz = (double) from.centerZ() - to.centerZ(); + double len = Math.max(1.0, Math.hypot(dx, dz)); + double dist = (double) islandRange - margin; + return new int[] { to.centerX() + (int) Math.round(dx / len * dist), + to.centerZ() + (int) Math.round(dz / len * dist) }; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java b/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java new file mode 100644 index 0000000..87426d1 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java @@ -0,0 +1,102 @@ +package world.bentobox.tradewinds.travel; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.entity.Boat; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.vehicle.VehicleMoveEvent; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * The warp trigger: reaching an island's border in a boat offers the warp + * dialog (proximity trigger - the packet-level "click the wall" idea was + * rejected as needless complexity). Rowing on through does nothing special; + * the prompt has a per-island cooldown so it never spams. + * + * @author tastybento + */ +public class BorderPromptListener implements Listener { + + private final TradeWinds addon; + // player -> "cellX,cellZ@epochSeconds" of the last prompt + private final Map lastPrompt = new ConcurrentHashMap<>(); + + public BorderPromptListener(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onVehicleMove(VehicleMoveEvent event) { + if (!(event.getVehicle() instanceof Boat boat) || boat.getPassengers().isEmpty() + || !(boat.getPassengers().get(0) instanceof Player player)) { + return; + } + // Gate to block crossings + if (event.getFrom().getBlockX() == event.getTo().getBlockX() + && event.getFrom().getBlockZ() == event.getTo().getBlockZ()) { + return; + } + check(player, event.getTo().getBlockX(), event.getTo().getBlockZ()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerMove(PlayerMoveEvent event) { + // Some boat movement arrives as PlayerMoveEvent while seated + if (event.getPlayer().getVehicle() instanceof Boat + && (event.getFrom().getBlockX() != event.getTo().getBlockX() + || event.getFrom().getBlockZ() != event.getTo().getBlockZ())) { + check(event.getPlayer(), event.getTo().getBlockX(), event.getTo().getBlockZ()); + } + } + + private void check(Player player, int x, int z) { + if (!player.getWorld().equals(addon.getOverWorld())) { + return; + } + Optional origin = originIslandNearBorder(x, z); + if (origin.isEmpty()) { + return; + } + IslandSpec spec = origin.get(); + String key = spec.cellX() + "," + spec.cellZ(); + long now = System.currentTimeMillis() / 1000; + String last = lastPrompt.get(player.getUniqueId()); + if (last != null) { + String[] parts = last.split("@"); + if (parts[0].equals(key) + && now - Long.parseLong(parts[1]) < addon.getSettings().getWarpPromptCooldownSeconds()) { + return; + } + } + lastPrompt.put(player.getUniqueId(), key + "@" + now); + addon.getWarpService().openDialog(player, spec); + } + + /** + * The island whose border the position is just inside, if any: within the + * island's range, but within trigger-distance of its edge. + */ + Optional originIslandNearBorder(int x, int z) { + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + int range = addon.getSettings().getIslandDistance(); + long outer = (long) range * range; + int inner = range - addon.getSettings().getWarpTriggerDistance(); + long innerSq = (long) inner * inner; + return engine.islandsNear(x, z, range).stream() + .filter(spec -> { + long d2 = spec.distanceSquared(x, z); + return d2 <= outer && d2 >= innerSq; + }) + .findFirst(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java new file mode 100644 index 0000000..1f91d2a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java @@ -0,0 +1,70 @@ +package world.bentobox.tradewinds.travel; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import net.kyori.adventure.text.Component; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.IslandChartedEvent; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; + +/** + * Charting: the discovery layer over lazy generation. Physically entering an + * island's range puts it on your chart - only charted islands appear in the + * warp dialog. Movement checks are gated to chunk crossings to stay cheap. + * + * @author tastybento + */ +public class ChartingListener implements Listener { + + private final TradeWinds addon; + + public ChartingListener(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + // Load (and pre-chart the starter cluster for) the player's data + addon.getPlayerDataManager().get(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + addon.getPlayerDataManager().unload(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onMove(PlayerMoveEvent event) { + // Only check when crossing a chunk boundary + if (event.getFrom().getBlockX() >> 4 == event.getTo().getBlockX() >> 4 + && event.getFrom().getBlockZ() >> 4 == event.getTo().getBlockZ() >> 4) { + return; + } + Player player = event.getPlayer(); + if (!player.getWorld().equals(addon.getOverWorld())) { + return; + } + int x = event.getTo().getBlockX(); + int z = event.getTo().getBlockZ(); + int range = addon.getSettings().getIslandDistance(); + TWPlayerData data = addon.getPlayerDataManager().get(player.getUniqueId()); + addon.getGalaxyEngine(player.getWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(spec -> spec.distanceSquared(x, z) <= (long) range * range) + .filter(data::chart) + .forEach(spec -> { + User user = User.getInstance(player); + player.sendActionBar(Component.text( + user.getTranslation("tradewinds.chart.charted", "[name]", spec.name()))); + addon.getPlayerDataManager().save(player.getUniqueId()); + Bukkit.getPluginManager().callEvent(new IslandChartedEvent(player.getUniqueId(), spec)); + }); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/FuelService.java b/src/main/java/world/bentobox/tradewinds/travel/FuelService.java new file mode 100644 index 0000000..248ff6d --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/FuelService.java @@ -0,0 +1,155 @@ +package world.bentobox.tradewinds.travel; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import org.bukkit.Material; +import org.bukkit.entity.ChestBoat; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Warp fuel accounting. Fuel lives ONLY in the hold - the chest boat's + * inventory plus trading bundles carried by the player (spec principle 1: + * nothing gameplay-relevant may bypass the hold). Plain inventory items in the + * player's pockets do not count. + *

+ * Stage 3 hold definition: chest-boat inventory + bundle contents. Stage 4 + * extends the same accessor to cargo expanders. + * + * @author tastybento + */ +public class FuelService { + + private final TradeWinds addon; + + public FuelService(TradeWinds addon) { + this.addon = addon; + } + + /** + * One consumable stack found in the hold, with a handle to remove from the + * right container. + */ + private record FuelStack(ItemStack stack, double unitValue, Runnable shrinkOne) { + } + + /** + * The fuel unit value of a material, or 0 if it is not fuel. + */ + public double fuelValue(Material material) { + return addon.getSettings().getFuelValues().getOrDefault(material.name(), 0.0); + } + + /** + * Total fuel units aboard. + * + * @param player the (boated) player + * @return fuel units in the hold + */ + public double holdFuel(Player player) { + return holdStacks(player).stream().mapToDouble(f -> f.unitValue() * f.stack().getAmount()).sum(); + } + + /** + * Consume at least {@code units} of fuel from the hold, cheapest items + * first. Overshoot is burned - warping is not a precise science. Lava + * buckets leave their empty bucket behind. + * + * @param player the player + * @param units fuel units required + * @return true if consumed; false (nothing removed) if the hold has too little + */ + public boolean consume(Player player, double units) { + if (holdFuel(player) < units) { + return false; + } + double remaining = units; + // Cheapest first: burn wood before coal before lava + List stacks = holdStacks(player); + stacks.sort(Comparator.comparingDouble(FuelStack::unitValue)); + for (FuelStack fuel : stacks) { + while (remaining > 0 && fuel.stack().getAmount() > 0) { + fuel.shrinkOne().run(); + remaining -= fuel.unitValue(); + } + if (remaining <= 0) { + break; + } + } + return true; + } + + /** + * Every fuel stack in the hold: chest-boat inventory plus bundles in the + * player's inventory. + */ + private List holdStacks(Player player) { + List result = new ArrayList<>(); + if (player.getVehicle() instanceof ChestBoat boat) { + Inventory inv = boat.getInventory(); + for (ItemStack stack : inv.getContents()) { + addIfFuel(result, stack, () -> shrinkInInventory(inv, stack)); + } + } + // Bundles anywhere in the player inventory count as hold space + for (ItemStack carried : player.getInventory().getContents()) { + if (carried != null && carried.getItemMeta() instanceof org.bukkit.inventory.meta.BundleMeta bundle) { + for (ItemStack inside : bundle.getItems()) { + addIfFuel(result, inside, () -> shrinkInBundle(carried, inside)); + } + } + } + return result; + } + + private void addIfFuel(List result, ItemStack stack, Runnable shrinkOne) { + if (stack == null || stack.getType().isAir()) { + return; + } + double value = fuelValue(stack.getType()); + if (value > 0) { + result.add(new FuelStack(stack, value, shrinkOne)); + } + } + + private void shrinkInInventory(Inventory inv, ItemStack stack) { + if (stack.getType() == Material.LAVA_BUCKET) { + stack.setType(Material.BUCKET); + } else { + stack.setAmount(stack.getAmount() - 1); + if (stack.getAmount() <= 0) { + inv.remove(stack); + } + } + } + + private void shrinkInBundle(ItemStack bundleItem, ItemStack inside) { + if (!(bundleItem.getItemMeta() instanceof org.bukkit.inventory.meta.BundleMeta bundle)) { + return; + } + List items = new ArrayList<>(bundle.getItems()); + for (ItemStack item : items) { + if (item.equals(inside) || item.isSimilar(inside)) { + if (item.getType() == Material.LAVA_BUCKET) { + item.setType(Material.BUCKET); + inside.setType(Material.BUCKET); + } else { + item.setAmount(item.getAmount() - 1); + inside.setAmount(Math.max(0, inside.getAmount() - 1)); + if (item.getAmount() <= 0) { + items.remove(item); + } + } + break; + } + } + bundle.setItems(items); + bundleItem.setItemMeta(bundle); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java new file mode 100644 index 0000000..b6dd827 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -0,0 +1,180 @@ +package world.bentobox.tradewinds.travel; + +import java.util.Comparator; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.Boat; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; + +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.body.DialogBody; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.TWWarpCompletedEvent; +import world.bentobox.tradewinds.api.events.TWWarpEvent; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.RouteGraph; + +/** + * The warp: fuel-powered jump between charted islands. Opens the Paper dialog + * at the border, consumes fuel from the hold, executes the proven + * dismount/teleport/re-seat pattern, and lands the player just inside the + * destination border on the bearing of the origin island. + *

+ * Warp failure (the interstice detour) arrives in Stage 5 - until then every + * engaged warp delivers. + * + * @author tastybento + */ +public class WarpService { + + private final TradeWinds addon; + + public WarpService(TradeWinds addon) { + this.addon = addon; + } + + /** + * One entry in the warp dialog. + */ + public record Destination(IslandSpec island, int fuelCost, boolean affordable) { + } + + /** + * The destinations a player may warp to from an origin island: charted, + * not the origin, nearest first, capped by config. Pure selection logic, + * kept separate from dialog rendering for tests. + */ + public List destinations(Player player, IslandSpec origin, double fuelAboard) { + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + RouteGraph routes = addon.getRouteGraph(); + return addon.getPlayerDataManager().get(player.getUniqueId()).getChartedIslands().stream() + .map(key -> key.split(",")) + .map(cell -> engine.islandInCell(Integer.parseInt(cell[0]), Integer.parseInt(cell[1]))) + .flatMap(java.util.Optional::stream) + .filter(spec -> !(spec.cellX() == origin.cellX() && spec.cellZ() == origin.cellZ())) + .sorted(Comparator.comparingLong(spec -> spec.distanceSquared(origin.centerX(), origin.centerZ()))) + .limit(addon.getSettings().getMaxWarpDestinations()) + .map(spec -> { + int cost = routes.cost(origin, spec); + return new Destination(spec, cost, cost <= fuelAboard); + }) + .toList(); + } + + /** + * Show the warp dialog to a boated player at an island border. + */ + public void openDialog(Player player, IslandSpec origin) { + double fuelAboard = addon.getFuelService().holdFuel(player); + List destinations = destinations(player, origin, fuelAboard); + if (destinations.isEmpty()) { + player.sendMessage(user(player).getTranslation("tradewinds.warp.no-destinations")); + return; + } + List buttons = destinations.stream().map(dest -> button(player, origin, dest)).toList(); + Dialog dialog = Dialog.create(factory -> factory.empty() + .base(DialogBase.builder(Component.text("Warp - " + origin.name())) + .body(List.of(DialogBody.plainMessage(Component + .text("Fuel aboard: " + (int) fuelAboard + " units", NamedTextColor.AQUA)))) + .build()) + .type(DialogType.multiAction(buttons).columns(1).build())); + player.showDialog(dialog); + } + + private ActionButton button(Player player, IslandSpec origin, Destination dest) { + IslandSpec spec = dest.island(); + Component label = Component.text(spec.name() + " - " + dest.fuelCost() + " fuel", + dest.affordable() ? NamedTextColor.WHITE : NamedTextColor.DARK_GRAY); + Component tooltip = Component.text(spec.type().name() + ", " + spec.band().getDisplayName() + ", " + + (int) Math.sqrt(spec.distanceSquared(origin.centerX(), origin.centerZ())) + " blocks"); + DialogAction action = DialogAction.customClick( + (response, audience) -> { + if (dest.affordable()) { + warp(player, origin, spec, dest.fuelCost()); + } else { + player.sendMessage(user(player).getTranslation("tradewinds.warp.not-enough-fuel")); + } + }, ClickCallback.Options.builder().build()); + return ActionButton.create(label, tooltip, 250, action); + } + + /** + * Engage the warp: fuel is consumed here; the jump follows. + */ + public void warp(Player player, IslandSpec from, IslandSpec to, int fuelCost) { + TWWarpEvent event = new TWWarpEvent(player, from, to, fuelCost); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return; + } + if (!addon.getFuelService().consume(player, fuelCost)) { + player.sendMessage(user(player).getTranslation("tradewinds.warp.not-enough-fuel")); + return; + } + // Departure effects + Location here = player.getLocation(); + here.getWorld().spawnParticle(Particle.PORTAL, here, 80, 1, 1, 1, 0.5); + here.getWorld().playSound(here, Sound.BLOCK_PORTAL_TRAVEL, 0.4f, 1.2f); + + int[] arrive = RouteGraph.arrivalPoint(from, to, addon.getSettings().getIslandDistance(), + addon.getSettings().getWarpArrivalMargin()); + Location target = new Location(addon.getOverWorld(), arrive[0] + 0.5, + addon.getSettings().getSeaHeight() + 1.0, arrive[1] + 0.5); + + // Dismount -> teleport player and boat -> re-seat (AcidIsland /ai pattern) + Entity vehicle = player.getVehicle(); + if (vehicle != null) { + player.leaveVehicle(); + } + Util.teleportAsync(player, target).thenRun(() -> { + if (vehicle instanceof Boat boat && boat.isValid()) { + boat.teleportAsync(target).thenRun(() -> Bukkit.getScheduler().runTask(addon.getPlugin(), + () -> boat.addPassenger(player))); + } + arrivalEffects(player, target); + player.sendMessage(user(player).getTranslation("tradewinds.warp.arrived", "[name]", to.name())); + Bukkit.getPluginManager().callEvent(new TWWarpCompletedEvent(player, from, to, fuelCost)); + }); + } + + /** + * Warping hurts (lore: it gates the under-equipped): nausea, blindness and + * configurable minor damage, plus arrival spectacle. + */ + private void arrivalEffects(Player player, Location target) { + int nausea = addon.getSettings().getWarpNauseaSeconds(); + int blind = addon.getSettings().getWarpBlindnessSeconds(); + if (nausea > 0) { + player.addPotionEffect(new PotionEffect(PotionEffectType.NAUSEA, nausea * 20, 0)); + } + if (blind > 0) { + player.addPotionEffect(new PotionEffect(PotionEffectType.BLINDNESS, blind * 20, 0)); + } + double damage = addon.getSettings().getWarpDamage(); + if (damage > 0) { + player.damage(damage); + } + target.getWorld().spawnParticle(Particle.PORTAL, target, 80, 1, 1, 1, 0.5); + target.getWorld().playSound(target, Sound.BLOCK_PORTAL_TRAVEL, 0.4f, 0.8f); + } + + private world.bentobox.bentobox.api.user.User user(Player player) { + return world.bentobox.bentobox.api.user.User.getInstance(player); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a43826e..4405498 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -45,6 +45,38 @@ travel: # Chance (0.0-1.0) that a warp fails and drops the player into the interstice. # Re-engaging the warp from the interstice to the original destination is always free. failure-chance: 0.05 + # Distance inside an island's border at which a boated player is offered the warp dialog. + trigger-distance: 30 + # How far inside the destination border a warp arrival lands. + arrival-margin: 50 + # Seconds between automatic warp-dialog offers at the same island's border. + prompt-cooldown-seconds: 30 + # Maximum destinations listed in the warp dialog (nearest first). + max-destinations: 20 + # Warping hurts - it gates the under-equipped. + nausea-seconds: 8 + blindness-seconds: 3 + # Damage (in half-hearts) taken on warp arrival. 0 disables. + damage: 2.0 + # Per-edge fuel cost overrides ('cx,cz>cx,cz' smaller cell first -> absolute fuel units). + edge-overrides: {} + # Fuel unit value per material. Fuel is consumed from the hold only: the chest + # boat's inventory and trading bundles - never loose pockets. + fuel-values: + OAK_LOG: 1.0 + SPRUCE_LOG: 1.0 + BIRCH_LOG: 1.0 + JUNGLE_LOG: 1.0 + ACACIA_LOG: 1.0 + DARK_OAK_LOG: 1.0 + MANGROVE_LOG: 1.0 + CHERRY_LOG: 1.0 + DRIED_KELP_BLOCK: 4.0 + COAL: 8.0 + CHARCOAL: 8.0 + BLAZE_ROD: 12.0 + COAL_BLOCK: 80.0 + LAVA_BUCKET: 100.0 illegal-trade: # Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling. # Set false for family-friendly servers - removes the entire crime layer cleanly. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index ba749e6..55a5fc5 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -14,6 +14,10 @@ tradewinds: description: "TradeWinds trading command" spawn: description: "teleport to the ocean spawn" + warp: + description: "open the warp dialog (in a boat, at an island)" + chart: + description: "list the islands on your chart" admin: help: description: "admin command for TradeWinds" @@ -29,3 +33,14 @@ tradewinds: teleported: "&b Arrived at [name]." spawn: teleported: "&b You set sail on the open ocean." + chart: + charted: "Charted [name]!" + empty: "&c Your chart is empty - row into an island's waters to chart it." + header: "&b Your chart ([number] islands):" + entry: "&e [name] &7- [type], [band], [distance] blocks" + warp: + not-in-boat: "&c You need to be aboard a boat to warp." + not-at-island: "&c The warp only engages within an island's waters." + no-destinations: "&c No charted destinations - row into new waters first." + not-enough-fuel: "&c Not enough fuel aboard for that jump." + arrived: "&b The warp releases you at [name]." diff --git a/src/test/java/world/bentobox/tradewinds/dataobjects/TWPlayerDataTest.java b/src/test/java/world/bentobox/tradewinds/dataobjects/TWPlayerDataTest.java new file mode 100644 index 0000000..42a9194 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/dataobjects/TWPlayerDataTest.java @@ -0,0 +1,34 @@ +package world.bentobox.tradewinds.dataobjects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Tests the player chart data object. + * + * @author tastybento + */ +class TWPlayerDataTest { + + private final GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(9L, 2500, 160, 45, 1.0, 0, 5000, 70)); + + @Test + void testCharting() { + TWPlayerData data = new TWPlayerData("uuid"); + IslandSpec spec = engine.islandInCell(1, 2).orElseThrow(); + assertFalse(data.isCharted(spec)); + assertTrue(data.chart(spec)); + assertTrue(data.isCharted(spec)); + // Charting twice is not "newly charted" + assertFalse(data.chart(spec)); + assertEquals(1, data.getChartedIslands().size()); + assertEquals("1,2", TWPlayerData.chartKey(spec)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/RouteGraphTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/RouteGraphTest.java new file mode 100644 index 0000000..dc9b9f6 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/galaxy/RouteGraphTest.java @@ -0,0 +1,59 @@ +package world.bentobox.tradewinds.galaxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +/** + * Pure tests of warp route costs and arrival geometry. + * + * @author tastybento + */ +class RouteGraphTest { + + private final GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(42L, 2500, 160, 45, 1.0, 0, 5000, 70)); + private final IslandSpec a = engine.islandInCell(0, 0).orElseThrow(); + private final IslandSpec b = engine.islandInCell(2, 1).orElseThrow(); + + @Test + void testDistanceCost() { + RouteGraph graph = new RouteGraph(0.01, Map.of()); + double dist = Math.sqrt(a.distanceSquared(b.centerX(), b.centerZ())); + assertEquals((int) Math.ceil(dist * 0.01), graph.cost(a, b)); + // Symmetric + assertEquals(graph.cost(a, b), graph.cost(b, a)); + // Never free + assertTrue(new RouteGraph(0.0000001, Map.of()).cost(a, b) >= 1); + } + + @Test + void testEdgeKeyIsDirectionIndependent() { + assertEquals(RouteGraph.edgeKey(a, b), RouteGraph.edgeKey(b, a)); + } + + @Test + void testEdgeOverride() { + RouteGraph graph = new RouteGraph(0.01, Map.of(RouteGraph.edgeKey(a, b), 3.0)); + assertEquals(3, graph.cost(a, b)); + assertEquals(3, graph.cost(b, a)); + // Other edges unaffected + IslandSpec c = engine.islandInCell(-3, 4).orElseThrow(); + double dist = Math.sqrt(a.distanceSquared(c.centerX(), c.centerZ())); + assertEquals((int) Math.ceil(dist * 0.01), graph.cost(a, c)); + } + + @Test + void testArrivalPoint() { + int[] p = RouteGraph.arrivalPoint(a, b, 1000, 50); + // 950 blocks from the destination center... + double distFromDest = Math.sqrt(b.distanceSquared(p[0], p[1])); + assertEquals(950, distFromDest, 2.0); + // ...on the origin side: closer to the origin than the destination center is + double destToOrigin = Math.sqrt(b.distanceSquared(a.centerX(), a.centerZ())); + double arrivalToOrigin = Math.sqrt(a.distanceSquared(p[0], p[1])); + assertEquals(destToOrigin - 950, arrivalToOrigin, 2.0); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java b/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java new file mode 100644 index 0000000..a5d2549 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java @@ -0,0 +1,67 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +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.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Tests the warp-offer geometry: the dialog is offered just inside the border, + * not at the island center, not in open ocean. + * + * @author tastybento + */ +class BorderPromptListenerTest extends CommonTestSetup { + + private static final long SEED = 606L; + + private BorderPromptListener listener; + private IslandSpec spec; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + TradeWinds addon = mock(TradeWinds.class); + Settings settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); + when(addon.getOverWorld()).thenReturn(world); + when(world.getSeed()).thenReturn(SEED); + listener = new BorderPromptListener(addon); + spec = engine.islandInCell(0, 0).orElseThrow(); + } + + @Test + void testOfferedJustInsideBorder() { + // 10 blocks inside the border (range 1000, trigger 30) + Optional hit = listener.originIslandNearBorder(spec.centerX() + 990, spec.centerZ()); + assertTrue(hit.isPresent()); + assertEquals(spec, hit.get()); + } + + @Test + void testNotOfferedDeepInside() { + assertTrue(listener.originIslandNearBorder(spec.centerX() + 100, spec.centerZ()).isEmpty()); + assertTrue(listener.originIslandNearBorder(spec.centerX(), spec.centerZ()).isEmpty()); + } + + @Test + void testNotOfferedOutside() { + assertTrue(listener.originIslandNearBorder(spec.centerX() + 1100, spec.centerZ()).isEmpty()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/FuelServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/FuelServiceTest.java new file mode 100644 index 0000000..a58c80e --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/FuelServiceTest.java @@ -0,0 +1,105 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.Material; +import org.bukkit.entity.ChestBoat; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +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.TradeWinds; + +/** + * Tests fuel accounting: hold-only sourcing, cheapest-first consumption, the + * lava-bucket rule, and the no-fuel-outside-the-hold invariant. + * + * @author tastybento + */ +class FuelServiceTest extends CommonTestSetup { + + private TradeWinds addon; + private FuelService service; + private ChestBoat boat; + private Inventory boatInv; + private ItemStack coal; + private ItemStack logs; + private ItemStack lava; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + when(addon.getSettings()).thenReturn(new Settings()); + service = new FuelService(addon); + + coal = new ItemStack(Material.COAL, 4); // 32 units + logs = new ItemStack(Material.OAK_LOG, 10); // 10 units + lava = new ItemStack(Material.LAVA_BUCKET, 1); // 100 units + + boat = mock(ChestBoat.class); + boatInv = mock(Inventory.class); + when(boat.getInventory()).thenReturn(boatInv); + when(boatInv.getContents()).thenReturn(new ItemStack[] { coal, logs, lava, null }); + when(mockPlayer.getVehicle()).thenReturn(boat); + // Player pockets: loose coal that must NOT count (not in the hold) + PlayerInventory pockets = mock(PlayerInventory.class); + when(pockets.getContents()).thenReturn(new ItemStack[] { new ItemStack(Material.COAL, 64), null }); + when(mockPlayer.getInventory()).thenReturn(pockets); + } + + @Test + void testHoldFuelCountsOnlyTheHold() { + // 32 + 10 + 100 = 142; the 64 pocket coal (512 units) must not count + assertEquals(142.0, service.holdFuel(mockPlayer)); + } + + @Test + void testNoBoatNoFuel() { + when(mockPlayer.getVehicle()).thenReturn(null); + assertEquals(0.0, service.holdFuel(mockPlayer)); + assertFalse(service.consume(mockPlayer, 1)); + } + + @Test + void testConsumeCheapestFirst() { + // 12 units: all 10 logs (1 each), then 1 coal (8) - overshoot burned + assertTrue(service.consume(mockPlayer, 12)); + assertEquals(0, logs.getAmount()); + assertEquals(3, coal.getAmount()); + assertEquals(Material.LAVA_BUCKET, lava.getType()); + } + + @Test + void testConsumeRefusesWhenShort() { + assertFalse(service.consume(mockPlayer, 143)); + // Nothing was removed + assertEquals(4, coal.getAmount()); + assertEquals(10, logs.getAmount()); + } + + @Test + void testLavaBucketLeavesEmptyBucket() { + // 140 units forces the lava bucket to burn + assertTrue(service.consume(mockPlayer, 140)); + assertEquals(Material.BUCKET, lava.getType()); + } + + @Test + void testFuelValues() { + assertEquals(8.0, service.fuelValue(Material.COAL)); + assertEquals(1.0, service.fuelValue(Material.OAK_LOG)); + assertEquals(100.0, service.fuelValue(Material.LAVA_BUCKET)); + assertEquals(0.0, service.fuelValue(Material.DIAMOND)); + assertEquals(0.0, service.fuelValue(Material.DIRT)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java b/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java new file mode 100644 index 0000000..db7c433 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java @@ -0,0 +1,110 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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.TradeWinds; +import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.RouteGraph; + +/** + * Tests the warp dialog's destination selection: charted-only, origin + * excluded, nearest first, affordability flags, capped. + * + * @author tastybento + */ +class WarpSelectionTest extends CommonTestSetup { + + private static final long SEED = 2026L; + + private TradeWinds addon; + private GalaxyEngine engine; + private WarpService service; + private TWPlayerData data; + private IslandSpec origin; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + Settings settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); + when(addon.getOverWorld()).thenReturn(world); + when(world.getSeed()).thenReturn(SEED); + when(addon.getRouteGraph()).thenReturn(new RouteGraph(0.01, Map.of())); + PlayerDataManager pdm = mock(PlayerDataManager.class); + when(addon.getPlayerDataManager()).thenReturn(pdm); + data = new TWPlayerData(uuid.toString()); + when(pdm.get(uuid)).thenReturn(data); + when(mockPlayer.getUniqueId()).thenReturn(uuid); + service = new WarpService(addon); + origin = engine.islandInCell(0, 0).orElseThrow(); + } + + @Test + void testOnlyChartedAndNotOrigin() { + // Nothing charted -> nothing offered + assertTrue(service.destinations(mockPlayer, origin, 1000).isEmpty()); + // Chart the origin and two neighbors + data.chart(origin); + IslandSpec near = engine.islandInCell(0, 1).orElseThrow(); + IslandSpec far = engine.islandInCell(5, 5).orElseThrow(); + data.chart(near); + data.chart(far); + List list = service.destinations(mockPlayer, origin, 1000); + assertEquals(2, list.size()); + // Origin never offered; nearest first + assertEquals(near, list.get(0).island()); + assertEquals(far, list.get(1).island()); + } + + @Test + void testAffordability() { + IslandSpec near = engine.islandInCell(0, 1).orElseThrow(); + data.chart(near); + RouteGraph graph = new RouteGraph(0.01, Map.of()); + int cost = graph.cost(origin, near); + List rich = service.destinations(mockPlayer, origin, cost); + assertTrue(rich.get(0).affordable()); + assertEquals(cost, rich.get(0).fuelCost()); + List poor = service.destinations(mockPlayer, origin, cost - 1.0); + assertFalse(poor.get(0).affordable()); + } + + @Test + void testDestinationCap() { + // Chart a large area + for (int cx = -4; cx <= 4; cx++) { + for (int cz = -4; cz <= 4; cz++) { + engine.islandInCell(cx, cz).ifPresent(data::chart); + } + } + List list = service.destinations(mockPlayer, origin, 100000); + assertEquals(addon.getSettings().getMaxWarpDestinations(), list.size()); + // Sorted by distance from the origin + for (int i = 1; i < list.size(); i++) { + assertTrue(list.get(i - 1).island().distanceSquared(origin.centerX(), origin.centerZ()) + <= list.get(i).island().distanceSquared(origin.centerX(), origin.centerZ())); + } + } +} From 3160b5a3949b56734bfd4e489f36e4beee1a070e Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 21:19:51 -0700 Subject: [PATCH 008/112] Resident protection, band flag policy, navigation boss bar, starter kit - bands.* config: per-band monster spawning, PvP, hurt-villagers rank; applied at registration, /twadmin reflag for existing islands - Residents: mobs never target them, only players can hurt them, tether teleports strays home, killed residents respawn after a delay - /tw settings restored (visitors get a read-only view of the island they stand on; ops edit via /twadmin settings) - Navigation boss bar: island | standing | dock distance, band-colored, fills as you approach the pier - Starter kit: trading bundle + owned boat on first spawn, seated on water; carried boats auto-launch at spawn - MockBukkit patcher: added damage_type registry diff (sulfur_cube_hot) - 84 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 56 +++++-- docs/PROGRESS.md | 34 +++++ scripts/build_patched_mockbukkit.py | 2 + .../world/bentobox/tradewinds/Settings.java | 79 ++++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 40 +++++ .../commands/AdminReflagCommand.java | 48 ++++++ .../tradewinds/commands/TWSpawnCommand.java | 8 +- .../tradewinds/dataobjects/TWPlayerData.java | 14 ++ .../tradewinds/galaxy/GalaxyEngine.java | 8 + .../generator/GalaxyIslandRegistrar.java | 18 ++- .../tradewinds/generator/IslandDecorator.java | 54 ++++--- .../listeners/ResidentProtectionListener.java | 60 ++++++++ .../tradewinds/tasks/NavigationBarTask.java | 139 ++++++++++++++++++ .../tradewinds/tasks/ResidentAuditTask.java | 128 ++++++++++++++++ .../tradewinds/travel/StarterKit.java | 113 ++++++++++++++ src/main/resources/config.yml | 34 +++++ src/main/resources/locales/en-US.yml | 7 + .../bentobox/tradewinds/SettingsTest.java | 16 ++ .../generator/GalaxyIslandRegistrarTest.java | 9 +- .../ResidentProtectionListenerTest.java | 109 ++++++++++++++ .../tasks/NavigationBarTaskTest.java | 62 ++++++++ 21 files changed, 1008 insertions(+), 30 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/AdminReflagCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/listeners/ResidentProtectionListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java create mode 100644 src/main/java/world/bentobox/tradewinds/tasks/ResidentAuditTask.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/StarterKit.java create mode 100644 src/test/java/world/bentobox/tradewinds/listeners/ResidentProtectionListenerTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/tasks/NavigationBarTaskTest.java diff --git a/TESTING.md b/TESTING.md index b12c678..8d7a56d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -62,7 +62,7 @@ and delete the `tradewinds_world*` folders for a clean generation, then: in chunks generated by this build — visit islands you have NOT been to before (`/twadmin islands`, pick ones beyond previous exploration), or regenerate the world. -- [ ] Every island visited has a **dock**: a straight stone-brick quay with a plank deck +- [x] Every island visited has a **dock**: a straight stone-brick quay with a plank deck one block above the water, running from the island's flank out into open water. A boat can pull up alongside the deck end. - ~~see screenshot at /Users/ben/Downloads/2026-07-29_16.40.38.png. The dock always has a gap between it and the land. It'd be better to have it join the land, otherwise it's not going to be used much.~~ @@ -76,7 +76,7 @@ in chunks generated by this build — visit islands you have NOT been to before the center, four lantern posts, and 2–4 market stalls (fence posts + colored wool canopy + barrel). Stall canopy color matches the island type. - [x] Plaza edges blend into the terrain — no sheer walls around the plaza disc. -- [ ] **Villagers** (3–5) stand on the plaza, professions matching the island's economy +- [x] **Villagers** (3–5) stand on the plaza, professions matching the island's economy (FISHING → fishermen; AGRICULTURAL → farmer/butcher/shepherd; MINING → mason/toolsmith; INDUSTRIAL → smiths; LUXURY → librarian/cleric; …). Skin variant matches the biome (snowy islands → snow villagers, desert → desert, …). @@ -92,14 +92,14 @@ in chunks generated by this build — visit islands you have NOT been to before - [x] Leave (unload chunks) and return: villagers and golems are still there (persistent, no despawn). - [x] Restart the server and revisit: residents persist, and no duplicate set spawns. -- [ ] Same seed regeneration: dock bearing, plaza position, and stall layout are identical. +- [x] Same seed regeneration: dock bearing, plaza position, and stall layout are identical. ## Stage 2b — Island identity (type landmarks, dressing, configurable weights) Playtest feedback: islands looked the same apart from biome; INDUSTRIAL didn't read industrial. **Needs freshly generated island chunks**, as ever. -- [ ] Each island has a **type landmark** on the inland edge of the plaza (opposite the dock): +- [x] Each island has a **type landmark** on the inland edge of the plaza (opposite the dock): - INDUSTRIAL: brick chimney with a smoking signal fire on top (visible from the sea!), blast furnaces, anvil, coal/iron block piles at the foot. - MINING: timbered shaft head, rails, spoil heap, exposed ore blocks. @@ -108,15 +108,15 @@ industrial. **Needs freshly generated island chunks**, as ever. - FOREST: log pile with stripped logs. - LUXURY: chiseled-quartz fountain with potted flowers. - FROZEN: packed-ice beacon cairn with a lantern. -- [ ] **Plaza surface** varies by type: polished blackstone (INDUSTRIAL), smooth quartz +- [x] **Plaza surface** varies by type: polished blackstone (INDUSTRIAL), smooth quartz (LUXURY), cobblestone (MINING), podzol (FOREST), planks (FISHING/FROZEN), dirt path (AGRICULTURAL). -- [ ] A **type-colored banner** and lantern post fly at the seaward end of every quay — +- [x] A **type-colored banner** and lantern post fly at the seaward end of every quay — the island's "flag" as you approach by boat. -- [ ] **Workstations** beside each stall (blast furnace/grindstone/anvil on INDUSTRIAL, +- [x] **Workstations** beside each stall (blast furnace/grindstone/anvil on INDUSTRIAL, lectern/brewing stand on LUXURY, composter/smoker/loom on AGRICULTURAL, …). -- [ ] Villagers do NOT lose or change their professions near the workstations (locked by XP). -- [ ] `galaxy.type-weights` appears in config.yml; setting a type's weight to 0 and +- [x] Villagers do NOT lose or change their professions near the workstations (locked by XP). +- [x] `galaxy.type-weights` appears in config.yml; setting a type's weight to 0 and regenerating removes that type (needs world regen — weights are seed-shaping). ## Stage 3 — Travel: warp, fuel, charting @@ -148,3 +148,41 @@ a lava bucket 100 — see `travel.fuel-values`), and: - [ ] Chart persists across relog and server restart. - [ ] A player without a boat: `/tw warp` refuses ("aboard a boat"); the border prompt does not fire while swimming. + +## Stage 3b — Resident protection, band flags, navigation bar, starter kit + +Playtest feedback round: traders died to night mobs and scattered; no way to find the +dock after warping; spawn dropped you in open water with nothing. + +**Resident protection (works on EXISTING islands too — no fresh chunks needed for +the listener/tether; respawn accounting also applies everywhere):** +- [ ] Stand on a FRONTIER+ island at night: hostile mobs spawn but never chase or hit + villagers/golems; a zombie walks straight past the traders. +- [ ] On SAFE/POLICED islands: no hostile mobs spawn inside the protection range at + all (outside the 400 radius and in open ocean they still do). +- [ ] Hit a villager to make it flee: within ~30s the tether teleports it back to + the plaza. +- [ ] Kill a villager (on a non-SAFE island — on SAFE the hit is blocked outright by + the HURT_VILLAGERS flag): after ~10 minutes with the plaza loaded, a replacement + spawns at the plaza with the right profession (console logs the respawn). +- [ ] `/tw settings` while standing on a trading island: any player can VIEW the flags; + toggling is refused (unowned island, no rank). `/twadmin settings` edits work. +- [ ] Change a `bands.*` config value, `/twadmin reload` (or restart), `/twadmin reflag`: + existing islands pick up the new flags. + +**Navigation boss bar:** +- [ ] Entering any island's waters shows a boss bar: island name | Clean | Dock m. +- [ ] The distance falls as you boat toward the dock and the bar fills; it updates + about once a second. +- [ ] Bar color tracks the band: blue SAFE, green POLICED, yellow FRONTIER, red + LAWLESS, purple ANARCHIC. +- [ ] Leaving island waters (or the world) removes the bar. `hud.navigation-bossbar: + false` disables it entirely. + +**Starter kit:** +- [ ] A brand-new player's first `/tw` arrival: gets a gold-named "Trading Bundle", + and — since spawn is water — an oak boat appears with them seated in it. + Kit message shows once, ever (relog + `/tw spawn` again: no second kit). +- [ ] The starter boat has the owner recorded (no visible check yet — Stage 4+ uses it). +- [ ] Returning to spawn later WITH a boat item in inventory: it is placed and you are + seated (item consumed). Without one: you swim. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 24207ab..78b73ca 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,40 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 3b — Resident protection, band flags, nav bar, starter kit (2026-07-29) + +Playtest-driven package (traders died at night and scattered; no dock +navigation after warp; bare-handed spawn): + +- **Band flag policy** (`bands.*` config): per-band MONSTER_NATURAL_SPAWN + (SAFE/POLICED off - note: setting flags only govern the protection range, + so approaches stay dangerous), PVP, and HURT_VILLAGERS rank (SAFE=500 + prevents outright; elsewhere allowed - crime handled by Stage 6, not + invincibility). Applied at registration; `/twadmin reflag` re-applies to + existing islands after config changes. +- **Residents survive**: mobs never target them (EntityTargetLivingEntityEvent + cancel on the resident PDC tag); only player-caused damage lands; + `ResidentAuditTask` tethers strays home (HOME_KEY PDC, plain teleport) and + respawns killed residents after `residents.respawn-delay-minutes` using the + engine-deterministic staffing count (`GalaxyEngine.villagerCount`). + Rejected: wandering-trader night despawn (blocks night trading, more state). +- `/tw settings` restored - core targets the island AT the player's location, + so visitors get a read-only view for free (no rank = no toggling). +- **Navigation boss bar** (1s task): island name | standing | dock distance; + fills as you close on the pier; color per band. `TradeWinds.getPlayerStanding` is a + "Clean" stub until Stage 6. Config gate `hud.navigation-bossbar`. +- **Starter kit** (spec §4): first spawn arrival gives a named Trading Bundle + + oak boat - seated in it on water, item on land; `starterKitGiven` in + TWPlayerData. Boat owner UUID in PDC from day one. Repeat spawns auto-launch + a carried boat item. +- MockBukkit patch extended AGAIN for a 26.2 registry gap: + `keyed/damage_type.json` lacked `minecraft:sulfur_cube_hot` and + `org.bukkit.damage.DamageType` clinit died - added `damage_type` to the + KEYED diff; script synced back to GushBlock. (Third instance of this + pattern: any `No value for minecraft:X` in tests means another registry + file needs the diff treatment.) +- 84 tests green. + ## Stage 3 — Travel: warp, fuel, charting (2026-07-29) — CODE COMPLETE, awaiting in-game test **Verify-first resolved:** Paper 26.2 dialog API confirmed and used: diff --git a/scripts/build_patched_mockbukkit.py b/scripts/build_patched_mockbukkit.py index 944b05a..f4a040b 100755 --- a/scripts/build_patched_mockbukkit.py +++ b/scripts/build_patched_mockbukkit.py @@ -56,6 +56,8 @@ # 26.2 added biomes (e.g. minecraft:sulfur_caves); org.bukkit.block.Biome # initialises every constant, so one missing key kills the class. "worldgen/biome": ("org/bukkit/block/Biome.class", False, "ocean"), + # 26.2 added damage types (e.g. minecraft:sulfur_cube_hot); same pattern. + "damage_type": ("org/bukkit/damage/DamageType.class", False, "generic"), } NEW_REGISTRIES = { diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 5867125..38e1987 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -108,6 +108,71 @@ private static Map defaultTypeWeights() { return map; } + /* SECURITY BANDS */ + @ConfigComment("Hostile mob natural spawning inside the protection range, per security band.") + @ConfigComment("Outside the protection range (the island approaches and open ocean) mobs always spawn.") + @ConfigEntry(path = "bands.monster-natural-spawn") + private Map bandMonsterSpawn = defaultBandMonsterSpawn(); + + private static Map defaultBandMonsterSpawn() { + Map map = new HashMap<>(); + map.put("SAFE", false); + map.put("POLICED", false); + map.put("FRONTIER", true); + map.put("LAWLESS", true); + map.put("ANARCHIC", true); + return map; + } + + @ConfigComment("PvP inside island space, per security band.") + @ConfigEntry(path = "bands.pvp") + private Map bandPvp = defaultBandPvp(); + + private static Map defaultBandPvp() { + Map map = new HashMap<>(); + map.put("SAFE", false); + map.put("POLICED", false); + map.put("FRONTIER", false); + map.put("LAWLESS", true); + map.put("ANARCHIC", true); + return map; + } + + @ConfigComment("Minimum rank required to hurt villagers, per security band (0 = anyone/visitor,") + @ConfigComment("500 = member - effectively nobody on unowned trading islands).") + @ConfigComment("SAFE prevents it outright; lower bands allow it - and Stage 6 punishes it.") + @ConfigEntry(path = "bands.hurt-villagers-rank") + private Map bandHurtVillagersRank = defaultBandHurtVillagers(); + + private static Map defaultBandHurtVillagers() { + Map map = new HashMap<>(); + map.put("SAFE", 500); + map.put("POLICED", 0); + map.put("FRONTIER", 0); + map.put("LAWLESS", 0); + map.put("ANARCHIC", 0); + return map; + } + + /* RESIDENTS */ + @ConfigComment("Distance from the plaza center beyond which a resident is teleported home.") + @ConfigEntry(path = "residents.tether-radius") + private int residentTetherRadius = 24; + + @ConfigComment("Minutes before a killed resident respawns at the plaza. The market always recovers.") + @ConfigEntry(path = "residents.respawn-delay-minutes") + private int residentRespawnDelayMinutes = 10; + + @ConfigComment("Seconds between resident audits (tether check + respawn accounting).") + @ConfigEntry(path = "residents.audit-period-seconds") + private int residentAuditPeriodSeconds = 30; + + /* HUD */ + @ConfigComment("Show the navigation boss bar in island waters: island name, your standing,") + @ConfigComment("and the distance to the dock - so you can steer for it after a warp.") + @ConfigEntry(path = "hud.navigation-bossbar") + private boolean navigationBossbar = true; + /* TRAVEL */ @ConfigComment("Base warp fuel cost multiplier: fuel units per block of Euclidean route distance.") @ConfigComment("Per-edge overrides come later via the route-graph config.") @@ -2115,6 +2180,20 @@ public void setConcurrentIslands(int concurrentIslands) { public void setFuelValues(Map fuelValues) { this.fuelValues = fuelValues; } public Map getEdgeOverrides() { return edgeOverrides; } public void setEdgeOverrides(Map edgeOverrides) { this.edgeOverrides = edgeOverrides; } + public Map getBandMonsterSpawn() { return bandMonsterSpawn; } + public void setBandMonsterSpawn(Map bandMonsterSpawn) { this.bandMonsterSpawn = bandMonsterSpawn; } + public Map getBandPvp() { return bandPvp; } + public void setBandPvp(Map bandPvp) { this.bandPvp = bandPvp; } + public Map getBandHurtVillagersRank() { return bandHurtVillagersRank; } + public void setBandHurtVillagersRank(Map bandHurtVillagersRank) { this.bandHurtVillagersRank = bandHurtVillagersRank; } + public int getResidentTetherRadius() { return residentTetherRadius; } + public void setResidentTetherRadius(int residentTetherRadius) { this.residentTetherRadius = residentTetherRadius; } + public int getResidentRespawnDelayMinutes() { return residentRespawnDelayMinutes; } + public void setResidentRespawnDelayMinutes(int residentRespawnDelayMinutes) { this.residentRespawnDelayMinutes = residentRespawnDelayMinutes; } + public int getResidentAuditPeriodSeconds() { return residentAuditPeriodSeconds; } + public void setResidentAuditPeriodSeconds(int residentAuditPeriodSeconds) { this.residentAuditPeriodSeconds = residentAuditPeriodSeconds; } + public boolean isNavigationBossbar() { return navigationBossbar; } + public void setNavigationBossbar(boolean navigationBossbar) { this.navigationBossbar = navigationBossbar; } public boolean isIllegalTradeEnabled() { return illegalTradeEnabled; } public void setIllegalTradeEnabled(boolean illegalTradeEnabled) { this.illegalTradeEnabled = illegalTradeEnabled; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 56980f2..92869f6 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -19,10 +19,12 @@ import world.bentobox.bentobox.api.commands.island.DefaultPlayerCommand; import world.bentobox.bentobox.api.commands.island.IslandInfoCommand; import world.bentobox.bentobox.api.commands.island.IslandLanguageCommand; +import world.bentobox.bentobox.api.commands.island.IslandSettingsCommand; import world.bentobox.bentobox.api.configuration.Config; import world.bentobox.bentobox.api.configuration.WorldSettings; import world.bentobox.bentobox.lists.Flags; import world.bentobox.tradewinds.commands.AdminIslandsCommand; +import world.bentobox.tradewinds.commands.AdminReflagCommand; import world.bentobox.tradewinds.commands.AdminTpIslandCommand; import world.bentobox.tradewinds.commands.TWChartCommand; import world.bentobox.tradewinds.commands.TWSpawnCommand; @@ -30,9 +32,13 @@ import world.bentobox.tradewinds.dataobjects.PlayerDataManager; import world.bentobox.tradewinds.galaxy.RouteGraph; import world.bentobox.tradewinds.listeners.IntersticePortalListener; +import world.bentobox.tradewinds.listeners.ResidentProtectionListener; +import world.bentobox.tradewinds.tasks.NavigationBarTask; +import world.bentobox.tradewinds.tasks.ResidentAuditTask; import world.bentobox.tradewinds.travel.BorderPromptListener; import world.bentobox.tradewinds.travel.ChartingListener; import world.bentobox.tradewinds.travel.FuelService; +import world.bentobox.tradewinds.travel.StarterKit; import world.bentobox.tradewinds.travel.WarpService; import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; @@ -64,6 +70,9 @@ public class TradeWinds extends GameModeAddon { private FuelService fuelService; private WarpService warpService; private RouteGraph routeGraph; + private StarterKit starterKit; + private @Nullable ResidentAuditTask residentAuditTask; + private @Nullable NavigationBarTask navigationBarTask; /** * This addon uses the new chunk generation API for the sea bottom @@ -99,6 +108,7 @@ public void setup() { new TWWarpCommand(this); new TWChartCommand(this); new IslandInfoCommand(this); + new IslandSettingsCommand(this); new IslandLanguageCommand(this); } }; @@ -108,6 +118,7 @@ public void setup() { super.setup(); new AdminIslandsCommand(this); new AdminTpIslandCommand(this); + new AdminReflagCommand(this); } }; } @@ -144,8 +155,16 @@ public void onEnable() { fuelService = new FuelService(this); routeGraph = new RouteGraph(getSettings().getFuelPerBlock(), getSettings().getEdgeOverrides()); warpService = new WarpService(this); + starterKit = new StarterKit(this); registerListener(new ChartingListener(this)); registerListener(new BorderPromptListener(this)); + // Residents survive the night: no mob targeting, tether, respawn + registerListener(new ResidentProtectionListener()); + residentAuditTask = new ResidentAuditTask(this); + residentAuditTask.start(); + // Navigation boss bar: island, standing, distance to dock + navigationBarTask = new NavigationBarTask(this); + navigationBarTask.start(); // Deterministic ocean spawn on the sea surface at the galaxy origin if (islandWorld != null) { islandWorld.setSpawnLocation(0, getSettings().getSeaHeight() + 1, 0); @@ -154,6 +173,12 @@ public void onEnable() { @Override public void onDisable() { + if (residentAuditTask != null) { + residentAuditTask.stop(); + } + if (navigationBarTask != null) { + navigationBarTask.stop(); + } if (playerDataManager != null) { playerDataManager.saveAll(); } @@ -260,6 +285,21 @@ public RouteGraph getRouteGraph() { return routeGraph; } + public StarterKit getStarterKit() { + return starterKit; + } + + /** + * The player's legal standing, for HUDs. Until the reputation system + * (Stage 6) lands, everyone is Clean. + * + * @param playerId the player + * @return display name of the player's standing + */ + public String getPlayerStanding(java.util.UUID playerId) { + return "Clean"; + } + /** * The seeded galaxy for the overworld. Created on first use because the * effective seed may be the world's own seed (config galaxy.seed = 0), which diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminReflagCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminReflagCommand.java new file mode 100644 index 0000000..65f3560 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminReflagCommand.java @@ -0,0 +1,48 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.generator.GalaxyIslandRegistrar; + +/** + * Re-applies the configured security-band flags to every registered trading + * island - run after changing the bands.* config so existing islands pick up + * the new policy (newly registered islands always use current config). + * + * @author tastybento + */ +public class AdminReflagCommand extends CompositeCommand { + + public AdminReflagCommand(CompositeCommand parent) { + super(parent, "reflag"); + } + + @Override + public void setup() { + setPermission("admin.reflag"); + setDescription("tradewinds.commands.admin.reflag.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + GalaxyIslandRegistrar registrar = new GalaxyIslandRegistrar(addon); + long count = addon.getIslands().getIslands(addon.getOverWorld()).stream() + .filter(island -> island.getOwner() == null && island.getCenter() != null) + .map(island -> engine.islandAt(island.getCenter().getBlockX(), island.getCenter().getBlockZ()) + .map(spec -> { + registrar.applyBandFlags(island, spec); + return spec; + })) + .flatMap(java.util.Optional::stream) + .count(); + user.sendMessage("tradewinds.commands.admin.reflag.done", TextVariables.NUMBER, String.valueOf(count)); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java index 1950c8a..1d6d1cb 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java @@ -8,6 +8,7 @@ import world.bentobox.bentobox.api.commands.DelayedTeleportCommand; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.TradeWinds; /** * Teleports the player to the ocean spawn. TradeWinds has no spawn island (and @@ -33,8 +34,13 @@ public void setup() { @Override public boolean execute(User user, String label, List args) { Location spawn = getWorld().getSpawnLocation(); + TradeWinds addon = getAddon(); this.delayCommand(user, () -> Util.teleportAsync(user.getPlayer(), spawn) - .thenAccept(done -> user.sendMessage("tradewinds.spawn.teleported"))); + .thenAccept(done -> { + user.sendMessage("tradewinds.spawn.teleported"); + // First-timers get boat + bundle; boat carriers get launched + addon.getStarterKit().onSpawnArrival(user.getPlayer()); + })); return true; } } diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index 0703d4b..1968364 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -28,6 +28,12 @@ public class TWPlayerData implements DataObject { @Expose private Set chartedIslands = new HashSet<>(); + /** + * Whether the one-time starter kit (boat + trading bundle) has been given. + */ + @Expose + private boolean starterKitGiven; + public TWPlayerData() { // Required by the database } @@ -68,6 +74,14 @@ public void setUniqueId(String uniqueId) { this.uniqueId = uniqueId; } + public boolean isStarterKitGiven() { + return starterKitGiven; + } + + public void setStarterKitGiven(boolean starterKitGiven) { + this.starterKitGiven = starterKitGiven; + } + public Set getChartedIslands() { return chartedIslands; } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index 55daf4e..ddcf2ab 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -227,6 +227,14 @@ public int landLiftAt(int blockX, int blockZ) { return (int) Math.round(config.landLift() * best); } + /** + * The number of resident villagers an island supports (3-5, seeded). + * Deterministic so the respawn audit knows what "fully staffed" means. + */ + public int villagerCount(IslandSpec spec) { + return 3 + (int) Math.floorMod(Hashing.cellHash(config.seed(), spec.cellX(), spec.cellZ(), 0x4E51DE47L), 3); + } + /** * The dock/market plan for an island - pure geometry from the island's * seeded bearing. diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java index 46e1bd4..cde9159 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -72,8 +72,7 @@ private void register(IslandSpec spec, ChunkLoadEvent event) { return; } island.setName(spec.name()); - // Security band flags: PvP live in lawless space - island.setSettingsFlag(Flags.PVP_OVERWORLD, spec.band().isPvp()); + applyBandFlags(island, spec); if (island.getMetaData().isEmpty()) { island.setMetaData(new HashMap<>()); } @@ -82,4 +81,19 @@ private void register(IslandSpec spec, ChunkLoadEvent event) { addon.log("Registered trading island '" + spec.name() + "' (" + spec.type() + ", " + spec.band() + ") at " + spec.centerX() + "," + spec.centerZ()); } + + /** + * Apply the security band's configured flags to an island: PvP, hostile + * spawning within the protection range, and the hurt-villagers rank + * (SAFE prevents it outright; lower bands allow it - crime is Stage 6's + * problem). + */ + public void applyBandFlags(Island island, IslandSpec spec) { + String band = spec.band().name(); + island.setSettingsFlag(Flags.PVP_OVERWORLD, + addon.getSettings().getBandPvp().getOrDefault(band, spec.band().isPvp())); + island.setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, + addon.getSettings().getBandMonsterSpawn().getOrDefault(band, true)); + island.setFlag(Flags.HURT_VILLAGERS, addon.getSettings().getBandHurtVillagersRank().getOrDefault(band, 0)); + } } diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java index a6698a0..dcc7206 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java @@ -39,8 +39,10 @@ */ public class IslandDecorator extends BlockPopulator { - /** PDC key marking island residents (villagers, golems). */ + /** PDC key marking island residents (villagers, golems); value = island name. */ public static final NamespacedKey RESIDENT_KEY = NamespacedKey.fromString("tradewinds:resident"); + /** PDC key holding a resident's home position ("x,y,z") for the tether. */ + public static final NamespacedKey HOME_KEY = NamespacedKey.fromString("tradewinds:home"); private static final long SALT_DECOR = 0xDEC0AA7EL; @@ -271,9 +273,11 @@ private void buildStall(LimitedRegion region, IslandSpec spec, int sx, int y, in private void spawnResidents(IslandSpec spec, DockPlan plan, LimitedRegion region, Random rand, World world, int y) { - // Villagers: professions match the island's economy + // Villagers: professions match the island's economy. Count is engine- + // deterministic so the respawn audit knows what fully-staffed means. List professions = IslandPalette.professions(spec.type()); - int villagers = 3 + rand.nextInt(3); + int villagers = addon.getGalaxyEngine(addon.getOverWorld() == null ? 0 : addon.getOverWorld().getSeed()) + .villagerCount(spec); for (int i = 0; i < villagers; i++) { int vx = plan.plazaX() + rand.nextInt(9) - 4; int vz = plan.plazaZ() + rand.nextInt(9) - 4; @@ -282,16 +286,7 @@ private void spawnResidents(IslandSpec spec, DockPlan plan, LimitedRegion region } Location loc = new Location(world, vx + 0.5, y, vz + 0.5); Villager villager = region.createEntity(loc, Villager.class); - villager.setProfession(professions.get(i % professions.size())); - // A villager with zero trade XP and no claimed job site is reset to - // unemployed on first tick (and the stall barrels are fisherman job - // sites, so the survivors all turned fisherman). One XP point locks - // the assigned profession for good. - villager.setVillagerExperience(1); - villager.setVillagerType(villagerType(spec.biomeKey())); - villager.setPersistent(true); - villager.setRemoveWhenFarAway(false); - villager.getPersistentDataContainer().set(RESIDENT_KEY, PersistentDataType.STRING, spec.name()); + configureVillager(villager, spec, i, plan.plazaX(), y, plan.plazaZ()); region.addEntity(villager); } // Resident golems: civilization shows - none in anarchic space @@ -303,17 +298,42 @@ private void spawnResidents(IslandSpec spec, DockPlan plan, LimitedRegion region } Location loc = new Location(world, gx + 0.5, y, gz + 0.5); IronGolem golem = region.createEntity(loc, IronGolem.class); - golem.setPersistent(true); - golem.setRemoveWhenFarAway(false); - golem.getPersistentDataContainer().set(RESIDENT_KEY, PersistentDataType.STRING, spec.name()); + configureResident(golem, spec, plan.plazaX(), y, plan.plazaZ()); region.addEntity(golem); } } + /** + * Shared resident setup for spawn and respawn: persistence, tags, home. + */ + public static void configureResident(org.bukkit.entity.LivingEntity entity, IslandSpec spec, int homeX, int homeY, + int homeZ) { + entity.setPersistent(true); + entity.setRemoveWhenFarAway(false); + entity.getPersistentDataContainer().set(RESIDENT_KEY, PersistentDataType.STRING, spec.name()); + entity.getPersistentDataContainer().set(HOME_KEY, PersistentDataType.STRING, homeX + "," + homeY + "," + homeZ); + } + + /** + * Shared villager setup for spawn and respawn. + */ + public static void configureVillager(Villager villager, IslandSpec spec, int index, int homeX, int homeY, + int homeZ) { + List professions = IslandPalette.professions(spec.type()); + villager.setProfession(professions.get(index % professions.size())); + // A villager with zero trade XP and no claimed job site is reset to + // unemployed on first tick (and the stall barrels are fisherman job + // sites, so the survivors all turned fisherman). One XP point locks + // the assigned profession for good. + villager.setVillagerExperience(1); + villager.setVillagerType(villagerType(spec.biomeKey())); + configureResident(villager, spec, homeX, homeY, homeZ); + } + /** * Resident golems by band - the visible face of island security. */ - static int golemCount(SecurityBand band) { + public static int golemCount(SecurityBand band) { return switch (band) { case SAFE -> 3; case POLICED -> 2; diff --git a/src/main/java/world/bentobox/tradewinds/listeners/ResidentProtectionListener.java b/src/main/java/world/bentobox/tradewinds/listeners/ResidentProtectionListener.java new file mode 100644 index 0000000..8b145d7 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/listeners/ResidentProtectionListener.java @@ -0,0 +1,60 @@ +package world.bentobox.tradewinds.listeners; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityTargetLivingEntityEvent; +import org.bukkit.persistence.PersistentDataType; + +import world.bentobox.tradewinds.generator.IslandDecorator; + +/** + * Keeps island residents (traders, golems) alive without making markets + * timeless fortresses: hostile mobs never target or hurt them, the environment + * cannot kill them - but PLAYERS can, where the island's HURT_VILLAGERS flag + * allows. Player violence against traders is a crime, and crime is handled by + * the reputation system (Stage 6), not by invincibility. + * + * @author tastybento + */ +public class ResidentProtectionListener implements Listener { + + private boolean isResident(Entity entity) { + return entity.getPersistentDataContainer().has(IslandDecorator.RESIDENT_KEY, PersistentDataType.STRING); + } + + /** + * Mobs never even look at residents - a zombie in an anarchic market + * wanders straight past the traders. + */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onTarget(EntityTargetLivingEntityEvent event) { + if (event.getTarget() != null && isResident(event.getTarget()) && !(event.getEntity() instanceof Player)) { + event.setCancelled(true); + } + } + + /** + * Residents only take damage from players (directly or via projectiles). + */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onDamage(EntityDamageEvent event) { + if (!isResident(event.getEntity())) { + return; + } + if (event instanceof EntityDamageByEntityEvent byEntity && isPlayerCaused(byEntity.getDamager())) { + return; // a player's problem is the reputation system's problem + } + event.setCancelled(true); + } + + private boolean isPlayerCaused(Entity damager) { + return damager instanceof Player + || (damager instanceof Projectile projectile && projectile.getShooter() instanceof Player); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java new file mode 100644 index 0000000..1bdb5b9 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java @@ -0,0 +1,139 @@ +package world.bentobox.tradewinds.tasks; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.DockPlan; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * The navigation boss bar: while in an island's waters it shows the island + * name, the player's standing, and the distance to the dock - steer toward the + * dock and watch the number fall. Bar color tracks the security band, and the + * bar fills as you close on the pier. + * + * @author tastybento + */ +public class NavigationBarTask implements Runnable { + + private final TradeWinds addon; + private final Map bars = new ConcurrentHashMap<>(); + private BukkitTask task; + + public NavigationBarTask(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this, 20L, 20L); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + bars.forEach((id, bar) -> { + Player player = Bukkit.getPlayer(id); + if (player != null) { + player.hideBossBar(bar); + } + }); + bars.clear(); + } + + @Override + public void run() { + if (!addon.getSettings().isNavigationBossbar() || addon.getOverWorld() == null) { + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + if (!player.getWorld().equals(addon.getOverWorld())) { + hide(player); + continue; + } + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + Optional reading = reading(engine, player.getLocation().getBlockX(), + player.getLocation().getBlockZ(), addon.getSettings().getIslandDistance()); + if (reading.isEmpty()) { + hide(player); + continue; + } + show(player, reading.get()); + } + } + + /** + * What the bar should show at a position, if anything. Pure and testable. + * + * @param engine the galaxy + * @param x player block x + * @param z player block z + * @param range island space radius + * @return the reading, or empty in open ocean + */ + static Optional reading(GalaxyEngine engine, int x, int z, int range) { + return engine.islandsNear(x, z, range).stream() + .filter(spec -> spec.distanceSquared(x, z) <= (long) range * range) + .findFirst() + .map(spec -> { + DockPlan plan = engine.dockPlan(spec); + int pierX = spec.centerX() + (int) Math.round(Math.cos(plan.bearing()) * plan.dockEnd()); + int pierZ = spec.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * plan.dockEnd()); + int dist = (int) Math.hypot((double) x - pierX, (double) z - pierZ); + float progress = (float) Math.clamp(1.0 - (double) dist / range, 0.0, 1.0); + return new Reading(spec, dist, progress); + }); + } + + /** + * One bar state: the island, distance to its pier, and bar fill. + */ + record Reading(IslandSpec island, int dockDistance, float progress) { + } + + private void show(Player player, Reading reading) { + Component name = Component.text(reading.island().name(), NamedTextColor.AQUA) + .append(Component.text(" | " + addon.getPlayerStanding(player.getUniqueId()), + NamedTextColor.WHITE)) + .append(Component.text(" | Dock " + reading.dockDistance() + "m", NamedTextColor.YELLOW)); + BossBar bar = bars.computeIfAbsent(player.getUniqueId(), + id -> BossBar.bossBar(name, reading.progress(), color(reading.island().band()), + BossBar.Overlay.PROGRESS)); + bar.name(name); + bar.progress(reading.progress()); + bar.color(color(reading.island().band())); + player.showBossBar(bar); + } + + private void hide(Player player) { + BossBar bar = bars.remove(player.getUniqueId()); + if (bar != null) { + player.hideBossBar(bar); + } + } + + /** + * Bar color tracks the band: calm blue in high-sec, red in lawless space. + */ + static BossBar.Color color(SecurityBand band) { + return switch (band) { + case SAFE -> BossBar.Color.BLUE; + case POLICED -> BossBar.Color.GREEN; + case FRONTIER -> BossBar.Color.YELLOW; + case LAWLESS -> BossBar.Color.RED; + case ANARCHIC -> BossBar.Color.PURPLE; + }; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/tasks/ResidentAuditTask.java b/src/main/java/world/bentobox/tradewinds/tasks/ResidentAuditTask.java new file mode 100644 index 0000000..7f90070 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/tasks/ResidentAuditTask.java @@ -0,0 +1,128 @@ +package world.bentobox.tradewinds.tasks; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.IronGolem; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Villager; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.DockPlan; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.generator.IslandDecorator; + +/** + * The resident keeper: a periodic sweep over loaded residents and plazas that + * (a) teleports stray residents home (villagers scatter when players hit them; + * the tether quietly puts the market back together) and (b) respawns killed + * residents after a configurable delay - a massacred market always recovers. + * + * @author tastybento + */ +public class ResidentAuditTask implements Runnable { + + private final TradeWinds addon; + private BukkitTask task; + // Island name -> epoch millis when a deficit was first noticed + private final Map deficitSince = new HashMap<>(); + + public ResidentAuditTask(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + long period = addon.getSettings().getResidentAuditPeriodSeconds() * 20L; + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this, period, period); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + } + + @Override + public void run() { + World world = addon.getOverWorld(); + if (world == null) { + return; + } + GalaxyEngine engine = addon.getGalaxyEngine(world.getSeed()); + // Tether: return loaded strays to their home + for (Entity entity : world.getEntities()) { + String home = entity.getPersistentDataContainer().get(IslandDecorator.HOME_KEY, + PersistentDataType.STRING); + if (home != null) { + tether(entity, home); + } + } + // Respawn: audit islands whose plaza chunks are loaded + for (org.bukkit.entity.Player player : world.getPlayers()) { + engine.islandsNear(player.getLocation().getBlockX(), player.getLocation().getBlockZ(), + engine.getConfig().terrainRadius() * 2).forEach(spec -> audit(world, engine, spec)); + } + } + + private void tether(Entity entity, String home) { + String[] parts = home.split(","); + Location homeLoc = new Location(entity.getWorld(), Integer.parseInt(parts[0]) + 0.5, + Integer.parseInt(parts[1]) + 1.0, Integer.parseInt(parts[2]) + 0.5); + int radius = addon.getSettings().getResidentTetherRadius(); + if (entity.getLocation().distanceSquared(homeLoc) > (double) radius * radius) { + entity.teleport(homeLoc); + } + } + + private void audit(World world, GalaxyEngine engine, IslandSpec spec) { + DockPlan plan = engine.dockPlan(spec); + if (!world.isChunkLoaded(plan.plazaX() >> 4, plan.plazaZ() >> 4)) { + return; + } + int surface = engine.getConfig().seaLevel() + GalaxyEngine.PLAZA_RISE; + Location plaza = new Location(world, plan.plazaX() + 0.5, surface + 1.0, plan.plazaZ() + 0.5); + List nearby = List.copyOf(world.getNearbyEntities(plaza, 48, 24, 48, + e -> spec.name().equals(e.getPersistentDataContainer().get(IslandDecorator.RESIDENT_KEY, + PersistentDataType.STRING)))); + long villagers = nearby.stream().filter(Villager.class::isInstance).count(); + long golems = nearby.stream().filter(IronGolem.class::isInstance).count(); + int wantVillagers = engine.villagerCount(spec); + int wantGolems = IslandDecorator.golemCount(spec.band()); + if (villagers >= wantVillagers && golems >= wantGolems) { + deficitSince.remove(spec.name()); + return; + } + long since = deficitSince.computeIfAbsent(spec.name(), k -> System.currentTimeMillis()); + if (System.currentTimeMillis() - since < addon.getSettings().getResidentRespawnDelayMinutes() * 60_000L) { + return; + } + // The market recovers: respawn the missing residents at the plaza + for (long i = villagers; i < wantVillagers; i++) { + Villager villager = world.spawn(plaza, Villager.class); + IslandDecorator.configureVillager(villager, spec, (int) i, plan.plazaX(), surface, plan.plazaZ()); + } + for (long i = golems; i < wantGolems; i++) { + IronGolem golem = world.spawn(plaza, IronGolem.class); + IslandDecorator.configureResident(golem, spec, plan.plazaX(), surface, plan.plazaZ()); + } + deficitSince.remove(spec.name()); + addon.log("Respawned residents at " + spec.name() + " (" + (wantVillagers - villagers) + " villagers, " + + (wantGolems - golems) + " golems)"); + } + + /** + * Visible for tests: whether an entity would be tethered from a position. + */ + boolean wouldTether(LivingEntity entity, Location homeLoc) { + int radius = addon.getSettings().getResidentTetherRadius(); + return entity.getLocation().distanceSquared(homeLoc) > (double) radius * radius; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java new file mode 100644 index 0000000..a4864c5 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java @@ -0,0 +1,113 @@ +package world.bentobox.tradewinds.travel; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; + +/** + * The start of every trading career: a boat and one trading bundle (spec §4). + * Given once, on the player's first spawn. When the spawn point is water the + * boat is launched and the player seated in it; on land the boat goes in the + * inventory instead. Repeat visitors to spawn who carry a boat item get + * auto-launched too - nobody treads water at spawn. + * + * @author tastybento + */ +public class StarterKit { + + /** PDC key on boats recording the owning player's UUID (spec §4). */ + public static final NamespacedKey BOAT_OWNER_KEY = NamespacedKey.fromString("tradewinds:owner"); + + private final TradeWinds addon; + + public StarterKit(TradeWinds addon) { + this.addon = addon; + } + + /** + * Handle a player arriving at spawn: first-timers get the kit; anyone with + * a boat to hand gets launched if the spawn is on water. + * + * @param player the player, already teleported to spawn + */ + public void onSpawnArrival(Player player) { + TWPlayerData data = addon.getPlayerDataManager().get(player.getUniqueId()); + Location spawn = player.getLocation(); + boolean onWater = spawn.getBlock().getType() == Material.WATER + || spawn.getBlock().getRelative(org.bukkit.block.BlockFace.DOWN).getType() == Material.WATER; + if (!data.isStarterKitGiven()) { + data.setStarterKitGiven(true); + addon.getPlayerDataManager().save(player.getUniqueId()); + player.getInventory().addItem(tradingBundle()); + if (onWater) { + launchBoat(player, spawn, Material.OAK_BOAT); + } else { + player.getInventory().addItem(new ItemStack(Material.OAK_BOAT)); + } + User.getInstance(player).sendMessage("tradewinds.starter-kit.given"); + return; + } + // Repeat arrival: auto-launch a carried boat so nobody swims at spawn + if (onWater && player.getVehicle() == null) { + Material carried = consumeBoatItem(player); + if (carried != null) { + launchBoat(player, spawn, carried); + } + } + } + + private ItemStack tradingBundle() { + ItemStack bundle = new ItemStack(Material.BUNDLE); + ItemMeta meta = bundle.getItemMeta(); + if (meta != null) { + meta.displayName(Component.text("Trading Bundle", NamedTextColor.GOLD)); + bundle.setItemMeta(meta); + } + return bundle; + } + + private void launchBoat(Player player, Location spawn, Material boatMaterial) { + // Boat item materials and boat entity types share names (OAK_BOAT...) + org.bukkit.entity.EntityType type; + try { + type = org.bukkit.entity.EntityType.valueOf(boatMaterial.name()); + } catch (IllegalArgumentException e) { + type = org.bukkit.entity.EntityType.OAK_BOAT; + } + org.bukkit.entity.Entity boat = spawn.getWorld().spawnEntity(spawn, type); + boat.getPersistentDataContainer().set(BOAT_OWNER_KEY, PersistentDataType.STRING, + player.getUniqueId().toString()); + boat.setPersistent(true); + boat.addPassenger(player); + } + + /** + * Remove one boat item from the inventory, plain boats preferred over + * chest boats. + * @return the removed boat material, or null if none carried + */ + private Material consumeBoatItem(Player player) { + Material found = null; + for (boolean allowChest : new boolean[] { false, true }) { + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getType().name().endsWith("_BOAT") + && (allowChest || !stack.getType().name().contains("CHEST"))) { + found = stack.getType(); + stack.setAmount(stack.getAmount() - 1); + return found; + } + } + } + return null; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4405498..637abff 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -77,6 +77,40 @@ travel: BLAZE_ROD: 12.0 COAL_BLOCK: 80.0 LAVA_BUCKET: 100.0 +bands: + # Hostile mob natural spawning inside the protection range, per security band. + # Outside the protection range (approaches and open ocean) mobs always spawn. + monster-natural-spawn: + SAFE: false + POLICED: false + FRONTIER: true + LAWLESS: true + ANARCHIC: true + # PvP inside island space, per security band. + pvp: + SAFE: false + POLICED: false + FRONTIER: false + LAWLESS: true + ANARCHIC: true + # Minimum rank to hurt villagers (0 = anyone, 500 = effectively nobody). + # SAFE prevents it outright; elsewhere it is allowed - and punished (Stage 6). + hurt-villagers-rank: + SAFE: 500 + POLICED: 0 + FRONTIER: 0 + LAWLESS: 0 + ANARCHIC: 0 +residents: + # Distance from the plaza center beyond which a resident is teleported home. + tether-radius: 24 + # Minutes before a killed resident respawns at the plaza. The market always recovers. + respawn-delay-minutes: 10 + # Seconds between resident audits (tether check + respawn accounting). + audit-period-seconds: 30 +hud: + # Navigation boss bar in island waters: island name, standing, distance to dock. + navigation-bossbar: true illegal-trade: # Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling. # Set false for family-friendly servers - removes the entire crime layer cleanly. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 55a5fc5..81567bc 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -31,8 +31,15 @@ tradewinds: parameters: "[number]" unknown: "&c No island with that number - run /twadmin islands first." teleported: "&b Arrived at [name]." + reflag: + description: "re-apply security-band flags to all trading islands" + done: "&a Re-applied band flags to [number] trading islands." spawn: teleported: "&b You set sail on the open ocean." + starter-kit: + given: "&6 Your trading career begins: a boat and a trading bundle. Buy low, sell high." + status: + clean: "Clean" chart: charted: "Charted [name]!" empty: "&c Your chart is empty - row into an island's waters to chart it." diff --git a/src/test/java/world/bentobox/tradewinds/SettingsTest.java b/src/test/java/world/bentobox/tradewinds/SettingsTest.java index 8c9644f..f4c3d16 100644 --- a/src/test/java/world/bentobox/tradewinds/SettingsTest.java +++ b/src/test/java/world/bentobox/tradewinds/SettingsTest.java @@ -85,6 +85,22 @@ void testGalaxyDefaults() { assertEquals(4, settings.getTypeWeights().get("LUXURY")); } + @Test + void testBandPolicies() { + // No hostile spawns in civilized space; PvP only in lawless space + assertFalse(settings.getBandMonsterSpawn().get("SAFE")); + assertFalse(settings.getBandMonsterSpawn().get("POLICED")); + assertTrue(settings.getBandMonsterSpawn().get("FRONTIER")); + assertTrue(settings.getBandPvp().get("ANARCHIC")); + assertFalse(settings.getBandPvp().get("FRONTIER")); + // SAFE protects villagers outright; elsewhere crime is possible + assertEquals(500, settings.getBandHurtVillagersRank().get("SAFE")); + assertEquals(0, settings.getBandHurtVillagersRank().get("ANARCHIC")); + assertEquals(24, settings.getResidentTetherRadius()); + assertEquals(10, settings.getResidentRespawnDelayMinutes()); + assertTrue(settings.isNavigationBossbar()); + } + @Test void testGameplayGates() { assertTrue(settings.isIllegalTradeEnabled()); diff --git a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java index 5b64735..b5f131e 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java @@ -80,7 +80,14 @@ void testRegistersIslandOnCenterChunkLoad() { assertEquals(spec.centerX() + 0.5, loc.getValue().getX()); assertEquals(spec.centerZ() + 0.5, loc.getValue().getZ()); verify(island).setName(spec.name()); - verify(island).setSettingsFlag(Flags.PVP_OVERWORLD, spec.band().isPvp()); + // Band policy: PvP, hostile spawning, villager protection + Settings settings = new Settings(); + verify(island).setSettingsFlag(Flags.PVP_OVERWORLD, + settings.getBandPvp().get(spec.band().name())); + verify(island).setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, + settings.getBandMonsterSpawn().get(spec.band().name())); + verify(island).setFlag(Flags.HURT_VILLAGERS, + settings.getBandHurtVillagersRank().get(spec.band().name())); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/listeners/ResidentProtectionListenerTest.java b/src/test/java/world/bentobox/tradewinds/listeners/ResidentProtectionListenerTest.java new file mode 100644 index 0000000..9f2c0ab --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/listeners/ResidentProtectionListenerTest.java @@ -0,0 +1,109 @@ +package world.bentobox.tradewinds.listeners; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.damage.DamageSource; +import org.bukkit.damage.DamageType; +import org.bukkit.entity.Arrow; +import org.bukkit.entity.Player; +import org.bukkit.entity.Villager; +import org.bukkit.entity.Zombie; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityTargetEvent.TargetReason; +import org.bukkit.event.entity.EntityTargetLivingEntityEvent; +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.generator.IslandDecorator; + +/** + * Tests resident protection: mobs never target or hurt residents; players can + * (crime is Stage 6's problem, not invincibility's). + * + * @author tastybento + */ +class ResidentProtectionListenerTest extends CommonTestSetup { + + private ResidentProtectionListener listener; + private Villager resident; + private Villager stranger; + private Zombie zombie; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + listener = new ResidentProtectionListener(); + resident = mock(Villager.class); + PersistentDataContainer taggedPdc = mock(PersistentDataContainer.class); + when(taggedPdc.has(IslandDecorator.RESIDENT_KEY, PersistentDataType.STRING)).thenReturn(true); + when(resident.getPersistentDataContainer()).thenReturn(taggedPdc); + stranger = mock(Villager.class); + when(stranger.getPersistentDataContainer()).thenReturn(mock(PersistentDataContainer.class)); + zombie = mock(Zombie.class); + } + + private DamageSource source() { + DamageSource src = mock(DamageSource.class); + when(src.getDamageType()).thenReturn(DamageType.GENERIC); + return src; + } + + @Test + void testMobsNeverTargetResidents() { + EntityTargetLivingEntityEvent event = new EntityTargetLivingEntityEvent(zombie, resident, + TargetReason.CLOSEST_ENTITY); + listener.onTarget(event); + assertTrue(event.isCancelled()); + // Ordinary villagers are still fair game for zombies + EntityTargetLivingEntityEvent other = new EntityTargetLivingEntityEvent(zombie, stranger, + TargetReason.CLOSEST_ENTITY); + listener.onTarget(other); + assertFalse(other.isCancelled()); + } + + @Test + void testMobDamageCancelled() { + EntityDamageByEntityEvent event = new EntityDamageByEntityEvent(zombie, resident, + EntityDamageEvent.DamageCause.ENTITY_ATTACK, source(), 4.0); + listener.onDamage(event); + assertTrue(event.isCancelled()); + } + + @Test + void testPlayerDamageAllowed() { + EntityDamageByEntityEvent direct = new EntityDamageByEntityEvent(mockPlayer, resident, + EntityDamageEvent.DamageCause.ENTITY_ATTACK, source(), 4.0); + listener.onDamage(direct); + assertFalse(direct.isCancelled()); + // Player projectiles too + Arrow arrow = mock(Arrow.class); + when(arrow.getShooter()).thenReturn(mock(Player.class)); + EntityDamageByEntityEvent shot = new EntityDamageByEntityEvent(arrow, resident, + EntityDamageEvent.DamageCause.PROJECTILE, source(), 4.0); + listener.onDamage(shot); + assertFalse(shot.isCancelled()); + } + + @Test + void testEnvironmentDamageCancelled() { + EntityDamageEvent event = new EntityDamageEvent(resident, EntityDamageEvent.DamageCause.FIRE, source(), 2.0); + listener.onDamage(event); + assertTrue(event.isCancelled()); + } + + @Test + void testStrangersUnprotected() { + EntityDamageByEntityEvent event = new EntityDamageByEntityEvent(zombie, stranger, + EntityDamageEvent.DamageCause.ENTITY_ATTACK, source(), 4.0); + listener.onDamage(event); + assertFalse(event.isCancelled()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/tasks/NavigationBarTaskTest.java b/src/test/java/world/bentobox/tradewinds/tasks/NavigationBarTaskTest.java new file mode 100644 index 0000000..6aa4f5f --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/tasks/NavigationBarTaskTest.java @@ -0,0 +1,62 @@ +package world.bentobox.tradewinds.tasks; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import net.kyori.adventure.bossbar.BossBar; +import world.bentobox.tradewinds.galaxy.DockPlan; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Tests the navigation bar reading: shown only in island waters, distance to + * the pier, fill rises as you close in. + * + * @author tastybento + */ +class NavigationBarTaskTest { + + private final GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(11L, 2500, 160, 45, 1.0, 0, 5000, 70)); + + @Test + void testReadingInIslandWaters() { + IslandSpec spec = engine.islandInCell(0, 0).orElseThrow(); + DockPlan plan = engine.dockPlan(spec); + int pierX = spec.centerX() + (int) Math.round(Math.cos(plan.bearing()) * plan.dockEnd()); + int pierZ = spec.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * plan.dockEnd()); + + Optional atPier = NavigationBarTask.reading(engine, pierX, pierZ, 1000); + assertTrue(atPier.isPresent()); + assertEquals(spec, atPier.get().island()); + assertEquals(0, atPier.get().dockDistance()); + assertEquals(1.0f, atPier.get().progress(), 0.01f); + + // At the island edge: far from the dock, low fill + Optional atEdge = NavigationBarTask.reading(engine, spec.centerX() + 995, + spec.centerZ(), 1000); + assertTrue(atEdge.isPresent()); + assertTrue(atEdge.get().dockDistance() > 700); + assertTrue(atEdge.get().progress() < atPier.get().progress()); + } + + @Test + void testNoReadingInOpenOcean() { + GalaxyEngine empty = new GalaxyEngine(new GalaxyConfig(11L, 2500, 160, 45, 0.0, 0, 5000, 70)); + assertTrue(NavigationBarTask.reading(empty, 500_000, 500_000, 1000).isEmpty()); + } + + @Test + void testBandColors() { + assertEquals(BossBar.Color.BLUE, NavigationBarTask.color(SecurityBand.SAFE)); + assertEquals(BossBar.Color.GREEN, NavigationBarTask.color(SecurityBand.POLICED)); + assertEquals(BossBar.Color.YELLOW, NavigationBarTask.color(SecurityBand.FRONTIER)); + assertEquals(BossBar.Color.RED, NavigationBarTask.color(SecurityBand.LAWLESS)); + assertEquals(BossBar.Color.PURPLE, NavigationBarTask.color(SecurityBand.ANARCHIC)); + } +} From 1c526b6fed8e9baf90c3284c6f752b7619582661 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 21:41:00 -0700 Subject: [PATCH 009/112] Fix warp offer: trigger and arrival at the visible border The protection edge (400) is the border players see - enter/leave messages and the Border wall live there. The warp offer ring now straddles it (was at the range edge ~1000, 550 blocks past the perceived border) and warp arrival lands just inside it (~350 from center) instead of far out at ~950. 84 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 14 +++++++++---- docs/PROGRESS.md | 14 +++++++++++++ .../world/bentobox/tradewinds/Settings.java | 5 +++-- .../travel/BorderPromptListener.java | 20 +++++++++++-------- .../tradewinds/travel/WarpService.java | 4 +++- src/main/resources/config.yml | 5 +++-- .../travel/BorderPromptListenerTest.java | 18 +++++++++++------ 7 files changed, 57 insertions(+), 23 deletions(-) diff --git a/TESTING.md b/TESTING.md index 8d7a56d..048ca09 100644 --- a/TESTING.md +++ b/TESTING.md @@ -127,17 +127,23 @@ a lava bucket 100 — see `travel.fuel-values`), and: - [ ] **Charting**: joining fresh, `/tw chart` already lists the starter-cluster islands (pre-charted). Rowing into a NEW island's waters (range 1000) pops "Charted !" on the action bar, and it appears in `/tw chart` with type/band/distance. -- [ ] **Warp offer**: rowing a boat to within ~30 blocks of an island's border pops the - warp dialog automatically (once per 30s per island). `/tw warp` opens it anywhere +- [ ] **Warp offer**: rowing a boat across an island's VISIBLE border (the protection + edge, ~400 from center — the moment "Now leaving " appears) pops the warp + dialog automatically (once per 30s per island). `/tw warp` opens it anywhere inside an island's waters while boated. + - ~~Playtest: no offer when rowing out past the leaving message~~ FIXED: the + trigger sat at the far range edge (~1000), 550 blocks of empty ocean later. + Now it fires right at the visible border. (Fuel never gates the offer — + unaffordable routes show greyed out.) - [ ] The dialog lists **charted islands only** (uncharted never appear), nearest first, with fuel cost per destination; the body shows fuel aboard. Unaffordable entries are dark grey and clicking them just says "not enough fuel". - [ ] **Warp**: clicking an affordable destination consumes fuel from the chest boat / bundles (check the inventory after), plays portal particles/sound, dismounts, teleports player AND boat, re-seats the player in the boat, applies ~8s nausea + - 3s blindness + 1 heart damage, and lands **just inside the destination's border on - the side facing the origin island** (~950 blocks from its center). + 3s blindness + 1 heart damage, and lands **just inside the destination's visible + border on the side facing the origin island** (~350 from its center — the island + is in sight, a short row from the dock). - [ ] Fuel in the player's pockets (not in chest boat or bundle) does NOT count and is never consumed — hold-only is the spec's core rule. - [ ] Lava bucket burns to an empty bucket (bucket stays aboard). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 78b73ca..b3a7d38 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,20 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest fix: warp offer at the visible border (2026-07-29) + +Ben rowed out of Tiabi, got "Now leaving", and no warp offer ever came. Root +cause: TWO borders. BentoBox's enter/leave messages and the Border addon's +wall are at the PROTECTION range (400); the warp trigger and arrival were at +the island-space range (~1000) - 550 blocks of dead ocean past the perceived +border. Fixed: the offer ring now straddles the protection edge (fires the +moment "Now leaving" appears; ring is trigger-distance wide each side so a +crossing cannot skip it), and warp arrival lands protection - margin (~350) +from the destination center - island in sight, short row to the dock. +Not-a-bug notes from the same session: the HUD keeps showing the island out +to the full range (island waters), the bar fill is closeness-to-dock +(1 - dist/range), and fuel never hides the warp dialog (greyed entries). + ## Stage 3b — Resident protection, band flags, nav bar, starter kit (2026-07-29) Playtest-driven package (traders died at night and scattered; no dock diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 38e1987..5fd9ad6 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -184,11 +184,12 @@ private static Map defaultBandHurtVillagers() { @ConfigEntry(path = "travel.warp.failure-chance") private double warpFailureChance = 0.05; - @ConfigComment("Distance inside an island's border at which a boated player is offered the warp dialog.") + @ConfigComment("Half-width of the offer ring around an island's visible border (the protection") + @ConfigComment("edge, where 'Now leaving...' appears): boated players crossing it get the warp dialog.") @ConfigEntry(path = "travel.warp.trigger-distance") private int warpTriggerDistance = 30; - @ConfigComment("How far inside the destination border a warp arrival lands.") + @ConfigComment("How far inside the destination's visible border (protection edge) a warp arrival lands.") @ConfigEntry(path = "travel.warp.arrival-margin") private int warpArrivalMargin = 50; diff --git a/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java b/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java index 87426d1..c9c2aa3 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java @@ -83,19 +83,23 @@ private void check(Player player, int x, int z) { } /** - * The island whose border the position is just inside, if any: within the - * island's range, but within trigger-distance of its edge. + * The island whose VISIBLE border the position is at, if any. The visible + * border is the protection range (where BentoBox announces "Now leaving + * ..." and the Border addon draws the wall) - the warp offer fires right + * at that moment, not out at the far edge of island space. The ring spans + * trigger-distance either side of the line so an outbound crossing cannot + * skip it. */ Optional originIslandNearBorder(int x, int z) { GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); - int range = addon.getSettings().getIslandDistance(); - long outer = (long) range * range; - int inner = range - addon.getSettings().getWarpTriggerDistance(); - long innerSq = (long) inner * inner; - return engine.islandsNear(x, z, range).stream() + int border = addon.getSettings().getIslandProtectionRange(); + int trigger = addon.getSettings().getWarpTriggerDistance(); + long outerSq = (long) (border + trigger) * (border + trigger); + long innerSq = (long) (border - trigger) * (border - trigger); + return engine.islandsNear(x, z, border + trigger).stream() .filter(spec -> { long d2 = spec.distanceSquared(x, z); - return d2 <= outer && d2 >= innerSq; + return d2 <= outerSq && d2 >= innerSq; }) .findFirst(); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index b6dd827..e6cd942 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -132,7 +132,9 @@ public void warp(Player player, IslandSpec from, IslandSpec to, int fuelCost) { here.getWorld().spawnParticle(Particle.PORTAL, here, 80, 1, 1, 1, 0.5); here.getWorld().playSound(here, Sound.BLOCK_PORTAL_TRAVEL, 0.4f, 1.2f); - int[] arrive = RouteGraph.arrivalPoint(from, to, addon.getSettings().getIslandDistance(), + // Arrive just inside the destination's VISIBLE border (the protection + // edge), on the bearing of the origin - close enough to see the island + int[] arrive = RouteGraph.arrivalPoint(from, to, addon.getSettings().getIslandProtectionRange(), addon.getSettings().getWarpArrivalMargin()); Location target = new Location(addon.getOverWorld(), arrive[0] + 0.5, addon.getSettings().getSeaHeight() + 1.0, arrive[1] + 0.5); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 637abff..8ed717a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -45,9 +45,10 @@ travel: # Chance (0.0-1.0) that a warp fails and drops the player into the interstice. # Re-engaging the warp from the interstice to the original destination is always free. failure-chance: 0.05 - # Distance inside an island's border at which a boated player is offered the warp dialog. + # Half-width of the offer ring around an island's visible border (the protection edge, + # where 'Now leaving...' appears): boated players crossing it get the warp dialog. trigger-distance: 30 - # How far inside the destination border a warp arrival lands. + # How far inside the destination's visible border (protection edge) a warp arrival lands. arrival-margin: 50 # Seconds between automatic warp-dialog offers at the same island's border. prompt-cooldown-seconds: 30 diff --git a/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java b/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java index a5d2549..cdd691e 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java @@ -47,11 +47,15 @@ public void setUp() throws Exception { } @Test - void testOfferedJustInsideBorder() { - // 10 blocks inside the border (range 1000, trigger 30) - Optional hit = listener.originIslandNearBorder(spec.centerX() + 990, spec.centerZ()); - assertTrue(hit.isPresent()); - assertEquals(spec, hit.get()); + void testOfferedAtVisibleBorder() { + // The visible border is the protection edge (400) - the offer fires + // exactly where "Now leaving..." appears (playtest regression: it used + // to sit at the range edge ~1000, 550 blocks of dead ocean later) + Optional inside = listener.originIslandNearBorder(spec.centerX() + 390, spec.centerZ()); + assertTrue(inside.isPresent()); + assertEquals(spec, inside.get()); + Optional outside = listener.originIslandNearBorder(spec.centerX() + 410, spec.centerZ()); + assertTrue(outside.isPresent()); } @Test @@ -61,7 +65,9 @@ void testNotOfferedDeepInside() { } @Test - void testNotOfferedOutside() { + void testNotOfferedBeyondTheRing() { + // Still inside island waters but past the ring: no repeat offers + assertTrue(listener.originIslandNearBorder(spec.centerX() + 600, spec.centerZ()).isEmpty()); assertTrue(listener.originIslandNearBorder(spec.centerX() + 1100, spec.centerZ()).isEmpty()); } } From 5482eb865975965605ee030a2f0350c9ac6c97f9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 21:50:28 -0700 Subject: [PATCH 010/112] Tune warp arrival: land 130 blocks from the destination center Replaces margin-inside-border with a direct travel.warp.arrival-distance (default 130, inside default view distance) - playtest found 350 a boring, disorienting paddle. 84 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 8 +++++--- docs/PROGRESS.md | 10 ++++++++++ .../java/world/bentobox/tradewinds/Settings.java | 11 ++++++----- .../bentobox/tradewinds/galaxy/RouteGraph.java | 16 ++++++++-------- .../bentobox/tradewinds/travel/WarpService.java | 7 +++---- src/main/resources/config.yml | 5 +++-- .../tradewinds/galaxy/RouteGraphTest.java | 8 ++++---- 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/TESTING.md b/TESTING.md index 048ca09..262a498 100644 --- a/TESTING.md +++ b/TESTING.md @@ -141,9 +141,11 @@ a lava bucket 100 — see `travel.fuel-values`), and: - [ ] **Warp**: clicking an affordable destination consumes fuel from the chest boat / bundles (check the inventory after), plays portal particles/sound, dismounts, teleports player AND boat, re-seats the player in the boat, applies ~8s nausea + - 3s blindness + 1 heart damage, and lands **just inside the destination's visible - border on the side facing the origin island** (~350 from its center — the island - is in sight, a short row from the dock). + 3s blindness + 1 heart damage, and lands **~130 blocks from the destination's + center on the side facing the origin island** — the island is right in front of + you at default view distance (`travel.warp.arrival-distance`). + - Playtest: 350 was too far — a boring paddle, and easy to get lost even with + the HUD. Retuned to 130 per test. - [ ] Fuel in the player's pockets (not in chest boat or bundle) does NOT count and is never consumed — hold-only is the spec's core rule. - [ ] Lava bucket burns to an empty bucket (bucket stays aboard). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b3a7d38..723a29b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,16 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest tune: warp arrival distance (2026-07-29) + +350-from-center arrivals were a boring paddle and easy to get lost from even +with the HUD. Config reworked from margin-inside-border to a direct +`travel.warp.arrival-distance` (blocks from destination center), default 130 - +inside the default 10-chunk view distance, so you materialize looking at your +destination. Note 130 is just inside the terrain radius (160): a high-noise +flank can occasionally mean shallow shelf water on arrival; boats cope. Add a +water-nudge only if playtests show beached arrivals. + ## Playtest fix: warp offer at the visible border (2026-07-29) Ben rowed out of Tiabi, got "Now leaving", and no warp offer ever came. Root diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 5fd9ad6..5749879 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -189,9 +189,10 @@ private static Map defaultBandHurtVillagers() { @ConfigEntry(path = "travel.warp.trigger-distance") private int warpTriggerDistance = 30; - @ConfigComment("How far inside the destination's visible border (protection edge) a warp arrival lands.") - @ConfigEntry(path = "travel.warp.arrival-margin") - private int warpArrivalMargin = 50; + @ConfigComment("How far from the destination island's CENTER a warp arrival lands. Default 130:") + @ConfigComment("inside the default view distance, so you materialize seeing your destination.") + @ConfigEntry(path = "travel.warp.arrival-distance") + private int warpArrivalDistance = 130; @ConfigComment("Seconds between automatic warp-dialog offers at the same island's border.") @ConfigEntry(path = "travel.warp.prompt-cooldown-seconds") @@ -2165,8 +2166,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWarpFailureChance(double warpFailureChance) { this.warpFailureChance = warpFailureChance; } public int getWarpTriggerDistance() { return warpTriggerDistance; } public void setWarpTriggerDistance(int warpTriggerDistance) { this.warpTriggerDistance = warpTriggerDistance; } - public int getWarpArrivalMargin() { return warpArrivalMargin; } - public void setWarpArrivalMargin(int warpArrivalMargin) { this.warpArrivalMargin = warpArrivalMargin; } + public int getWarpArrivalDistance() { return warpArrivalDistance; } + public void setWarpArrivalDistance(int warpArrivalDistance) { this.warpArrivalDistance = warpArrivalDistance; } public int getWarpPromptCooldownSeconds() { return warpPromptCooldownSeconds; } public void setWarpPromptCooldownSeconds(int warpPromptCooldownSeconds) { this.warpPromptCooldownSeconds = warpPromptCooldownSeconds; } public int getMaxWarpDestinations() { return maxWarpDestinations; } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/RouteGraph.java b/src/main/java/world/bentobox/tradewinds/galaxy/RouteGraph.java index 5c0076a..9e3504f 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/RouteGraph.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/RouteGraph.java @@ -52,21 +52,21 @@ public int cost(IslandSpec from, IslandSpec to) { } /** - * Warp arrival point: just inside the destination's border, on the bearing - * of the origin island - you arrive on the side you notionally came from. + * Warp arrival point: at arrival-distance from the destination's center, on + * the bearing of the origin island - you arrive on the side you notionally + * came from, close enough to see where you are going (default view + * distance is ~160 blocks). * * @param from origin island * @param to destination island - * @param islandRange the island space radius (border distance) - * @param margin how far inside the border to arrive + * @param arrivalDistance distance from the destination center to arrive at * @return {x, z} block position */ - public static int[] arrivalPoint(IslandSpec from, IslandSpec to, int islandRange, int margin) { + public static int[] arrivalPoint(IslandSpec from, IslandSpec to, int arrivalDistance) { double dx = (double) from.centerX() - to.centerX(); double dz = (double) from.centerZ() - to.centerZ(); double len = Math.max(1.0, Math.hypot(dx, dz)); - double dist = (double) islandRange - margin; - return new int[] { to.centerX() + (int) Math.round(dx / len * dist), - to.centerZ() + (int) Math.round(dz / len * dist) }; + return new int[] { to.centerX() + (int) Math.round(dx / len * arrivalDistance), + to.centerZ() + (int) Math.round(dz / len * arrivalDistance) }; } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index e6cd942..b3ad390 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -132,10 +132,9 @@ public void warp(Player player, IslandSpec from, IslandSpec to, int fuelCost) { here.getWorld().spawnParticle(Particle.PORTAL, here, 80, 1, 1, 1, 0.5); here.getWorld().playSound(here, Sound.BLOCK_PORTAL_TRAVEL, 0.4f, 1.2f); - // Arrive just inside the destination's VISIBLE border (the protection - // edge), on the bearing of the origin - close enough to see the island - int[] arrive = RouteGraph.arrivalPoint(from, to, addon.getSettings().getIslandProtectionRange(), - addon.getSettings().getWarpArrivalMargin()); + // Arrive close to the destination, on the bearing of the origin - + // inside view distance, so the island is right there in front of you + int[] arrive = RouteGraph.arrivalPoint(from, to, addon.getSettings().getWarpArrivalDistance()); Location target = new Location(addon.getOverWorld(), arrive[0] + 0.5, addon.getSettings().getSeaHeight() + 1.0, arrive[1] + 0.5); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 8ed717a..b3f8ed4 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -48,8 +48,9 @@ travel: # Half-width of the offer ring around an island's visible border (the protection edge, # where 'Now leaving...' appears): boated players crossing it get the warp dialog. trigger-distance: 30 - # How far inside the destination's visible border (protection edge) a warp arrival lands. - arrival-margin: 50 + # How far from the destination island's CENTER a warp arrival lands. Default 130: + # inside the default view distance, so you materialize seeing your destination. + arrival-distance: 130 # Seconds between automatic warp-dialog offers at the same island's border. prompt-cooldown-seconds: 30 # Maximum destinations listed in the warp dialog (nearest first). diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/RouteGraphTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/RouteGraphTest.java index dc9b9f6..461c11a 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/RouteGraphTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/RouteGraphTest.java @@ -47,13 +47,13 @@ void testEdgeOverride() { @Test void testArrivalPoint() { - int[] p = RouteGraph.arrivalPoint(a, b, 1000, 50); - // 950 blocks from the destination center... + int[] p = RouteGraph.arrivalPoint(a, b, 130); + // 130 blocks from the destination center (inside view distance)... double distFromDest = Math.sqrt(b.distanceSquared(p[0], p[1])); - assertEquals(950, distFromDest, 2.0); + assertEquals(130, distFromDest, 2.0); // ...on the origin side: closer to the origin than the destination center is double destToOrigin = Math.sqrt(b.distanceSquared(a.centerX(), a.centerZ())); double arrivalToOrigin = Math.sqrt(a.distanceSquared(p[0], p[1])); - assertEquals(destToOrigin - 950, arrivalToOrigin, 2.0); + assertEquals(destToOrigin - 130, arrivalToOrigin, 2.0); } } From cee20c382288f7b0f9eea39d2b349c7e130a5f12 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 22:02:19 -0700 Subject: [PATCH 011/112] Creeper-proof the markets: no block damage, players still fair game CREEPER_DAMAGE=false + CREEPER_GRIEFING=true asserted each enable for both worlds - the GRIEFING flag must stay true or core cancels whole explosions for non-members (everyone, on unowned trading islands). Residents were already immune to non-player damage. 84 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 7 +++++++ docs/PROGRESS.md | 12 ++++++++++++ .../java/world/bentobox/tradewinds/TradeWinds.java | 11 +++++++++++ 3 files changed, 30 insertions(+) diff --git a/TESTING.md b/TESTING.md index 262a498..7778ee9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -187,6 +187,13 @@ the listener/tether; respawn accounting also applies everywhere):** - [ ] Leaving island waters (or the world) removes the bar. `hud.navigation-bossbar: false` disables it entirely. +**Creeper griefing (playtest feedback):** +- [ ] Lure a creeper next to a market stall / landmark and let it explode: **no blocks + break** anywhere in the world, but the blast still damages you (and would damage + other players). Residents take no damage (mob damage is blocked for them). +- [ ] `world.flags` in config.yml shows `CREEPER_DAMAGE: false`, `CREEPER_GRIEFING: true` + after a restart (the addon re-asserts these each enable). + **Starter kit:** - [ ] A brand-new player's first `/tw` arrival: gets a gold-named "Trading Bundle", and — since spawn is water — an oak boat appears with them seated in it. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 723a29b..3461fb8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,18 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest fix: creeper-proof markets (2026-07-29) + +Creepers could be lured into the plaza to blow up stalls/landmarks. Fixed with +core's own flags, asserted each enable for both worlds: +`CREEPER_DAMAGE=false` (explosions break no blocks, still hurt entities) + +`CREEPER_GRIEFING=true` - counterintuitive but required: GRIEFING=false makes +core cancel the ENTIRE explosion when the creeper targets a non-member, and +everyone is a non-member on unowned trading islands, so creepers could not +even hurt players. Residents were already creeper-proof (non-player damage +cancelled). setDefaultSetting persists into world.flags, overriding any stale +lazily-saved true from earlier runs. + ## Playtest tune: warp arrival distance (2026-07-29) 350-from-center arrivals were a boring paddle and easy to get lost from even diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 92869f6..c9208da 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -146,6 +146,17 @@ public void onEnable() { } // Island names are announced on entry ("Now entering [name]") Flags.ENTER_EXIT_MESSAGES.setDefaultSetting(islandWorld, true); + // Markets must survive: creeper explosions never break blocks anywhere + // in TradeWinds (no lure-bombing the stalls or landmarks). GRIEFING + // stays true because false would cancel the whole explosion for + // non-members - and everyone is a non-member on a trading island; + // creepers killing players is legitimate (and, later, piracy). + Flags.CREEPER_DAMAGE.setDefaultSetting(islandWorld, false); + Flags.CREEPER_GRIEFING.setDefaultSetting(islandWorld, true); + if (netherWorld != null) { + Flags.CREEPER_DAMAGE.setDefaultSetting(netherWorld, false); + Flags.CREEPER_GRIEFING.setDefaultSetting(netherWorld, true); + } // Register trading islands lazily as their center chunks first load registerListener(new GalaxyIslandRegistrar(this)); // Seal both worlds against portals - the interstice is warp-failure-only From bfde2be8299338f181271dac3a44f651388876dd Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 22:45:36 -0700 Subject: [PATCH 012/112] =?UTF-8?q?Stage=204:=20economy=20and=20cargo=20?= =?UTF-8?q?=E2=80=94=20prices,=20trade=20dialogs,=20hold,=20expanders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PriceModel: produce/demand factors, band-scaled margins, stock drift with persisted per-island TWIslandData and hourly decay - BlueBook base prices via reflection (soft dep) over a config fallback table - HoldService: chest boat + expander shulkers + max 3 bundles; pockets never count; trade dialogs (sell/buy/shipwright) transact hold-only via Vault - Cargo expanders: purchase-only lore shulkers, doubling price, capped - TWTradeEvent for the Stage 6 contraband hook; item drop/pickup visitor- allowed on trading islands; starter kit deposits starting balance - 100 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 30 ++ docs/PROGRESS.md | 35 +++ pom.xml | 7 + .../world/bentobox/tradewinds/Settings.java | 111 +++++++ .../world/bentobox/tradewinds/TradeWinds.java | 36 +++ .../tradewinds/api/events/TWTradeEvent.java | 84 ++++++ .../tradewinds/commands/TWTradeCommand.java | 49 +++ .../dataobjects/IslandDataManager.java | 94 ++++++ .../tradewinds/dataobjects/TWIslandData.java | 72 +++++ .../tradewinds/dataobjects/TWPlayerData.java | 14 + .../tradewinds/economy/MarketService.java | 241 +++++++++++++++ .../tradewinds/economy/PriceModel.java | 80 +++++ .../tradewinds/economy/TradeCategory.java | 68 +++++ .../tradewinds/economy/TradeDialog.java | 145 +++++++++ .../tradewinds/economy/TradeListener.java | 43 +++ .../tradewinds/economy/TypeEconomy.java | 75 +++++ .../generator/GalaxyIslandRegistrar.java | 4 + .../tradewinds/travel/HoldService.java | 279 ++++++++++++++++++ .../tradewinds/travel/StarterKit.java | 4 + src/main/resources/config.yml | 51 ++++ src/main/resources/locales/en-US.yml | 12 + .../dataobjects/IslandDataManagerTest.java | 25 ++ .../tradewinds/economy/PriceModelTest.java | 64 ++++ .../tradewinds/economy/TradeCategoryTest.java | 77 +++++ .../tradewinds/travel/HoldServiceTest.java | 95 ++++++ 25 files changed, 1795 insertions(+) create mode 100644 src/main/java/world/bentobox/tradewinds/api/events/TWTradeEvent.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWTradeCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java create mode 100644 src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java create mode 100644 src/main/java/world/bentobox/tradewinds/economy/MarketService.java create mode 100644 src/main/java/world/bentobox/tradewinds/economy/PriceModel.java create mode 100644 src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java create mode 100644 src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java create mode 100644 src/main/java/world/bentobox/tradewinds/economy/TradeListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/HoldService.java create mode 100644 src/test/java/world/bentobox/tradewinds/dataobjects/IslandDataManagerTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java diff --git a/TESTING.md b/TESTING.md index 7778ee9..d36bb76 100644 --- a/TESTING.md +++ b/TESTING.md @@ -201,3 +201,33 @@ the listener/tether; respawn accounting also applies everywhere):** - [ ] The starter boat has the owner recorded (no visible check yet — Stage 4+ uses it). - [ ] Returning to spawn later WITH a boat item in inventory: it is placed and you are seated (item consumed). Without one: you swim. + +## Stage 4 — Economy and cargo + +Vault + an economy plugin must be running (they are on the test server). BlueBook is +optional — without it the fallback price table applies (console logs which). + +- [ ] New player: starter kit now also deposits the starting balance ($250). +- [ ] **Open the market**: right-click any resident villager, or `/tw trade` anywhere + within an island's protection range. Main menu shows balance, island type/band, + and Sell / Buy / Shipwright buttons. +- [ ] **Hold-only is enforced**: with wheat in your POCKETS and none in the hold, the + sell page says there is nothing to sell. Move it to the chest boat (or a bundle, + max 3 count) and it appears. This is the single most important economy check. +- [ ] **Buy low**: at an AGRICULTURAL island, crops are cheap (produce factor). Buy 16 + wheat — money leaves Vault balance, wheat lands in the chest boat. +- [ ] **Sell high**: haul it to an island that demands CROPS (FISHING) — the sell price + there beats what you paid. A same-island buy-then-sell always loses money. +- [ ] **Margins scale with danger**: the same demanded good pays visibly more at a + FRONTIER/LAWLESS island than at a SAFE one (band demand bonus). +- [ ] **Stock drift**: sell a large amount of one category at one island — its prices + for that category drop. Buy an island out — prices rise. Restart the server: + the drift persists (TWIslandData in the database). +- [ ] **LUXURY islands** sell nothing (no Buy button) but pay handsomely for gems, + luxuries, and fish. +- [ ] **Shipwright**: buy a Cargo Expander ($5000) — a gold-named shulker box placed + into your chest boat. The next one costs $10,000; the cap (4) refuses further + purchases. Goods inside the expander count for selling and fuel. +- [ ] Expanders cannot be crafted (no shulker shells exist — no End). +- [ ] A 4th bundle in your inventory does NOT add hold space (max-bundles 3). +- [ ] Without a chest boat and bundles, buying refuses with "no room in your hold". diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3461fb8..8e710cc 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,41 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 4 — Economy and cargo (2026-07-29) — CODE COMPLETE, awaiting in-game test + +The game becomes a game: buy low, sell high. + +- **Prices**: `PriceModel` (pure record, fully tested invariants: produce-> + demand routes profitable, same-island round trips always lose, margins + scale with band, stock drift clamps). Base prices from **BlueBook via + reflection** (soft dependency through AddonsManager - no compile-time + coupling; not in ~/.m2) with a ~50-entry fallback table in config. + `TradeCategory` classifies materials by name heuristics; `TypeEconomy` + holds the produce/demand tables and sale catalogs (code content for now, + config later). LUXURY produces nothing - pure demand sink in dangerous + space. +- **Stock drift**: `TWIslandData` (keyed by cell) persists per-category + stock; selling floods depress prices, buyouts raise them; lazy decay + toward equilibrium per hour on access. +- **The hold** (`HoldService`): chest-boat inventory + expander shulker + contents + up to `max-bundles` (3) bundles. contents/count/remove/add with + space handling. Pocket items are invisible to the market - tested. +- **Trade dialogs**: main (balance, sell/buy/shipwright) -> sell page (one + button per hold material with island prices) -> buy page (catalog in + 16-batches, limited by balance then by hold space - pay only for what + fit). Entry: right-click a resident trader (vanilla trade screen + suppressed) or `/tw trade` in protection range. `TWTradeEvent` + (cancellable) fires before every trade - Stage 6's contraband hook. +- **Cargo expanders**: PDC-marked gold-named shulker boxes, purchase-only + (no End -> no shells), price doubling per owned (`expander-base-price`, + `expander-cap`), stowed directly into the hold. +- **Vault**: BentoBox VaultHook; starter kit now deposits + `economy.starting-balance`. Needed the VaultAPI provided dependency in the + pom (VaultHook signatures reference EconomyResponse). +- Item drop/pickup explicitly set to visitor rank on trading islands (Ben's + request; also a Stage 6 jettison prerequisite). +- 100 tests green. + ## Playtest fix: creeper-proof markets (2026-07-29) Creepers could be lured into the plaza to blow up stalls/landmarks. Fixed with diff --git a/pom.xml b/pom.xml index dbd3c33..9c76d44 100644 --- a/pom.xml +++ b/pom.xml @@ -169,6 +169,13 @@ ${bentobox.version} provided + + + com.github.MilkBowl + VaultAPI + 1.7 + provided + diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 5749879..675a77b 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -241,6 +241,89 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "travel.warp.edge-overrides") private Map edgeOverrides = new HashMap<>(); + /* ECONOMY */ + @ConfigComment("Money given to brand-new players with their starter kit.") + @ConfigEntry(path = "economy.starting-balance") + private double startingBalance = 250.0; + + @ConfigComment("Multiplier on what players PAY an island (buying).") + @ConfigEntry(path = "economy.buy-spread") + private double buySpread = 1.15; + + @ConfigComment("Multiplier on what an island PAYS players (selling). Together with buy-spread") + @ConfigComment("this makes a same-island round trip always lose money.") + @ConfigEntry(path = "economy.sell-spread") + private double sellSpread = 0.85; + + @ConfigComment("Price multiplier for categories an island produces - its own goods are cheap.") + @ConfigEntry(path = "economy.produce-factor") + private double produceFactor = 0.6; + + @ConfigComment("Price multiplier for categories an island demands - it pays over the odds.") + @ConfigEntry(path = "economy.demand-factor") + private double demandFactor = 1.4; + + @ConfigComment("Extra demand multiplier per security band step - margins scale with danger:") + @ConfigComment("an ANARCHIC island pays (1 + 4 x this) times more on demanded goods.") + @ConfigEntry(path = "economy.band-demand-bonus") + private double bandDemandBonus = 0.125; + + @ConfigComment("Stock units for a full price swing. Selling this much of a category to one") + @ConfigComment("island drives its prices to the drift minimum.") + @ConfigEntry(path = "economy.drift-scale") + private int driftScale = 500; + + @ConfigComment("Lower clamp of the stock drift price factor.") + @ConfigEntry(path = "economy.drift-min") + private double driftMin = 0.7; + + @ConfigComment("Upper clamp of the stock drift price factor.") + @ConfigEntry(path = "economy.drift-max") + private double driftMax = 1.3; + + @ConfigComment("Stock units that decay back toward equilibrium per hour - markets recover.") + @ConfigEntry(path = "economy.stock-decay-per-hour") + private int stockDecayPerHour = 50; + + @ConfigComment("Base price of the first cargo expander. Each further one costs double.") + @ConfigEntry(path = "economy.expander-base-price") + private double expanderBasePrice = 5000.0; + + @ConfigComment("Maximum cargo expanders a player may ever buy.") + @ConfigEntry(path = "economy.expander-cap") + private int expanderCap = 4; + + @ConfigComment("Maximum trading bundles that count as hold space.") + @ConfigEntry(path = "economy.max-bundles") + private int maxBundles = 3; + + @ConfigComment("Base prices used when BlueBook is not installed. Material -> price.") + @ConfigEntry(path = "economy.fallback-prices") + private Map fallbackPrices = defaultFallbackPrices(); + + private static Map defaultFallbackPrices() { + Map map = new HashMap<>(); + map.put("WHEAT", 2.0); map.put("CARROT", 1.5); map.put("POTATO", 1.5); map.put("BEETROOT", 1.5); + map.put("SUGAR_CANE", 1.0); map.put("SUGAR", 1.5); map.put("PUMPKIN", 3.0); map.put("MELON_SLICE", 0.5); + map.put("BREAD", 3.0); map.put("COOKED_BEEF", 4.0); map.put("COOKED_COD", 3.0); map.put("CAKE", 20.0); + map.put("GOLDEN_APPLE", 150.0); map.put("EGG", 1.0); map.put("HAY_BLOCK", 18.0); + map.put("COD", 2.0); map.put("SALMON", 3.0); map.put("TROPICAL_FISH", 5.0); map.put("PUFFERFISH", 4.0); + map.put("KELP", 0.3); + map.put("OAK_LOG", 1.5); map.put("SPRUCE_LOG", 1.5); map.put("BIRCH_LOG", 1.5); map.put("DARK_OAK_LOG", 1.5); + map.put("ACACIA_LOG", 1.5); map.put("JUNGLE_LOG", 1.5); map.put("CHERRY_LOG", 2.0); + map.put("STONE", 0.5); map.put("COBBLESTONE", 0.3); map.put("GRANITE", 0.4); map.put("DIORITE", 0.4); + map.put("ANDESITE", 0.4); map.put("DEEPSLATE", 0.6); map.put("SAND", 0.3); map.put("GRAVEL", 0.3); + map.put("COAL", 4.0); map.put("CHARCOAL", 3.0); map.put("RAW_IRON", 6.0); map.put("RAW_COPPER", 3.0); + map.put("RAW_GOLD", 12.0); map.put("FLINT", 1.0); + map.put("IRON_INGOT", 9.0); map.put("COPPER_INGOT", 4.0); map.put("GOLD_INGOT", 18.0); + map.put("IRON_NUGGET", 1.0); map.put("GOLD_NUGGET", 2.0); + map.put("DIAMOND", 100.0); map.put("EMERALD", 60.0); map.put("AMETHYST_SHARD", 10.0); + map.put("QUARTZ", 8.0); map.put("LAPIS_LAZULI", 6.0); map.put("REDSTONE", 3.0); + map.put("LEATHER", 4.0); map.put("WHITE_WOOL", 2.0); map.put("STRING", 1.5); + map.put("BEEF", 2.5); map.put("PORKCHOP", 2.5); map.put("CHICKEN", 2.0); map.put("MUTTON", 2.0); + return map; + } + /* ILLEGAL TRADE */ @ConfigComment("Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling.") @ConfigComment("Set false for family-friendly servers - removes the entire crime layer cleanly.") @@ -2196,6 +2279,34 @@ public void setConcurrentIslands(int concurrentIslands) { public void setResidentAuditPeriodSeconds(int residentAuditPeriodSeconds) { this.residentAuditPeriodSeconds = residentAuditPeriodSeconds; } public boolean isNavigationBossbar() { return navigationBossbar; } public void setNavigationBossbar(boolean navigationBossbar) { this.navigationBossbar = navigationBossbar; } + public double getStartingBalance() { return startingBalance; } + public void setStartingBalance(double startingBalance) { this.startingBalance = startingBalance; } + public double getBuySpread() { return buySpread; } + public void setBuySpread(double buySpread) { this.buySpread = buySpread; } + public double getSellSpread() { return sellSpread; } + public void setSellSpread(double sellSpread) { this.sellSpread = sellSpread; } + public double getProduceFactor() { return produceFactor; } + public void setProduceFactor(double produceFactor) { this.produceFactor = produceFactor; } + public double getDemandFactor() { return demandFactor; } + public void setDemandFactor(double demandFactor) { this.demandFactor = demandFactor; } + public double getBandDemandBonus() { return bandDemandBonus; } + public void setBandDemandBonus(double bandDemandBonus) { this.bandDemandBonus = bandDemandBonus; } + public int getDriftScale() { return driftScale; } + public void setDriftScale(int driftScale) { this.driftScale = driftScale; } + public double getDriftMin() { return driftMin; } + public void setDriftMin(double driftMin) { this.driftMin = driftMin; } + public double getDriftMax() { return driftMax; } + public void setDriftMax(double driftMax) { this.driftMax = driftMax; } + public int getStockDecayPerHour() { return stockDecayPerHour; } + public void setStockDecayPerHour(int stockDecayPerHour) { this.stockDecayPerHour = stockDecayPerHour; } + public double getExpanderBasePrice() { return expanderBasePrice; } + public void setExpanderBasePrice(double expanderBasePrice) { this.expanderBasePrice = expanderBasePrice; } + public int getExpanderCap() { return expanderCap; } + public void setExpanderCap(int expanderCap) { this.expanderCap = expanderCap; } + public int getMaxBundles() { return maxBundles; } + public void setMaxBundles(int maxBundles) { this.maxBundles = maxBundles; } + public Map getFallbackPrices() { return fallbackPrices; } + public void setFallbackPrices(Map fallbackPrices) { this.fallbackPrices = fallbackPrices; } public boolean isIllegalTradeEnabled() { return illegalTradeEnabled; } public void setIllegalTradeEnabled(boolean illegalTradeEnabled) { this.illegalTradeEnabled = illegalTradeEnabled; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index c9208da..3081bc4 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -28,8 +28,13 @@ import world.bentobox.tradewinds.commands.AdminTpIslandCommand; import world.bentobox.tradewinds.commands.TWChartCommand; import world.bentobox.tradewinds.commands.TWSpawnCommand; +import world.bentobox.tradewinds.commands.TWTradeCommand; import world.bentobox.tradewinds.commands.TWWarpCommand; +import world.bentobox.tradewinds.dataobjects.IslandDataManager; import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.economy.MarketService; +import world.bentobox.tradewinds.economy.TradeDialog; +import world.bentobox.tradewinds.economy.TradeListener; import world.bentobox.tradewinds.galaxy.RouteGraph; import world.bentobox.tradewinds.listeners.IntersticePortalListener; import world.bentobox.tradewinds.listeners.ResidentProtectionListener; @@ -38,6 +43,7 @@ import world.bentobox.tradewinds.travel.BorderPromptListener; import world.bentobox.tradewinds.travel.ChartingListener; import world.bentobox.tradewinds.travel.FuelService; +import world.bentobox.tradewinds.travel.HoldService; import world.bentobox.tradewinds.travel.StarterKit; import world.bentobox.tradewinds.travel.WarpService; import world.bentobox.tradewinds.galaxy.GalaxyConfig; @@ -71,6 +77,10 @@ public class TradeWinds extends GameModeAddon { private WarpService warpService; private RouteGraph routeGraph; private StarterKit starterKit; + private HoldService holdService; + private IslandDataManager islandDataManager; + private MarketService marketService; + private TradeDialog tradeDialog; private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; @@ -107,6 +117,7 @@ public void setup() { new TWSpawnCommand(this); new TWWarpCommand(this); new TWChartCommand(this); + new TWTradeCommand(this); new IslandInfoCommand(this); new IslandSettingsCommand(this); new IslandLanguageCommand(this); @@ -167,6 +178,12 @@ public void onEnable() { routeGraph = new RouteGraph(getSettings().getFuelPerBlock(), getSettings().getEdgeOverrides()); warpService = new WarpService(this); starterKit = new StarterKit(this); + // Economy: hold, market data, prices, trade dialogs + holdService = new HoldService(this); + islandDataManager = new IslandDataManager(this); + marketService = new MarketService(this); + tradeDialog = new TradeDialog(this); + registerListener(new TradeListener(this)); registerListener(new ChartingListener(this)); registerListener(new BorderPromptListener(this)); // Residents survive the night: no mob targeting, tether, respawn @@ -193,6 +210,9 @@ public void onDisable() { if (playerDataManager != null) { playerDataManager.saveAll(); } + if (islandDataManager != null) { + islandDataManager.saveAll(); + } } @NonNull @@ -300,6 +320,22 @@ public StarterKit getStarterKit() { return starterKit; } + public HoldService getHoldService() { + return holdService; + } + + public IslandDataManager getIslandDataManager() { + return islandDataManager; + } + + public MarketService getMarketService() { + return marketService; + } + + public TradeDialog getTradeDialog() { + return tradeDialog; + } + /** * The player's legal standing, for HUDs. Until the reputation system * (Stage 6) lands, everyone is Clean. diff --git a/src/main/java/world/bentobox/tradewinds/api/events/TWTradeEvent.java b/src/main/java/world/bentobox/tradewinds/api/events/TWTradeEvent.java new file mode 100644 index 0000000..6da26e2 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/api/events/TWTradeEvent.java @@ -0,0 +1,84 @@ +package world.bentobox.tradewinds.api.events; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Fired before a market trade executes. Cancel to veto (Stage 6 uses this for + * contraband and Fugitive market bans). + * + * @author tastybento + */ +public class TWTradeEvent extends Event implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final IslandSpec island; + private final Material material; + private final int amount; + private final double unitPrice; + private final boolean playerSelling; + private boolean cancelled; + + public TWTradeEvent(Player player, IslandSpec island, Material material, int amount, double unitPrice, + boolean playerSelling) { + this.player = player; + this.island = island; + this.material = material; + this.amount = amount; + this.unitPrice = unitPrice; + this.playerSelling = playerSelling; + } + + public Player getPlayer() { + return player; + } + + public IslandSpec getIsland() { + return island; + } + + public Material getMaterial() { + return material; + } + + public int getAmount() { + return amount; + } + + public double getUnitPrice() { + return unitPrice; + } + + /** + * @return true if the player is selling to the island; false if buying + */ + public boolean isPlayerSelling() { + return playerSelling; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWTradeCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWTradeCommand.java new file mode 100644 index 0000000..e272c7f --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWTradeCommand.java @@ -0,0 +1,49 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; +import java.util.Optional; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Opens the island market from anywhere inside its protection range - the + * polite alternative to hunting down a trader on the plaza. + * + * @author tastybento + */ +public class TWTradeCommand extends CompositeCommand { + + public TWTradeCommand(CompositeCommand parent) { + super(parent, "trade"); + } + + @Override + public void setup() { + setPermission("island.trade"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.trade.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + if (!getWorld().equals(user.getWorld())) { + user.sendMessage("general.errors.wrong-world"); + return false; + } + int x = user.getLocation().getBlockX(); + int z = user.getLocation().getBlockZ(); + int range = addon.getSettings().getIslandProtectionRange(); + Optional spec = addon.getGalaxyEngine(getWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(s -> s.distanceSquared(x, z) <= (long) range * range).findFirst(); + if (spec.isEmpty()) { + user.sendMessage("tradewinds.trade.not-at-market"); + return false; + } + addon.getTradeDialog().openMain(user.getPlayer(), spec.get()); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java b/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java new file mode 100644 index 0000000..0cb96de --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java @@ -0,0 +1,94 @@ +package world.bentobox.tradewinds.dataobjects; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import world.bentobox.bentobox.database.Database; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.economy.TradeCategory; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Cache-in-front-of-Database manager for {@link TWIslandData}. Stock decays + * toward equilibrium lazily on access - markets recover from gluts and + * shortages over real time. + * + * @author tastybento + */ +public class IslandDataManager { + + private final TradeWinds addon; + private final Database handler; + private final Map cache = new ConcurrentHashMap<>(); + + public IslandDataManager(TradeWinds addon) { + this.addon = addon; + this.handler = new Database<>(addon, TWIslandData.class); + } + + private static String key(IslandSpec spec) { + return spec.cellX() + "," + spec.cellZ(); + } + + private TWIslandData get(IslandSpec spec) { + return cache.computeIfAbsent(key(spec), k -> { + if (handler.objectExists(k)) { + TWIslandData loaded = handler.loadObject(k); + if (loaded != null) { + return loaded; + } + } + return new TWIslandData(k); + }); + } + + /** + * The island's current stock for a category, decayed to now. + */ + public int getStock(IslandSpec spec, TradeCategory category) { + TWIslandData data = get(spec); + decay(data); + return data.getStock().getOrDefault(category.name(), 0); + } + + /** + * Adjust stock after a trade: players selling to the island push stock up + * (prices fall); buying pulls it down (prices rise). + */ + public void adjustStock(IslandSpec spec, TradeCategory category, int delta) { + TWIslandData data = get(spec); + decay(data); + data.getStock().merge(category.name(), delta, Integer::sum); + handler.saveObjectAsync(data); + } + + /** + * Lazy decay: every category trends toward equilibrium 0 at the configured + * units per hour since the last pass. + */ + private void decay(TWIslandData data) { + long now = System.currentTimeMillis(); + long hours = (now - data.getLastDecay()) / 3_600_000L; + if (hours <= 0) { + return; + } + int perHour = addon.getSettings().getStockDecayPerHour(); + data.getStock().replaceAll((cat, stock) -> decayed(stock, (int) Math.min(Integer.MAX_VALUE, hours * perHour))); + data.setLastDecay(now); + } + + /** + * Move a stock value toward zero by up to {@code amount}. Pure and + * package-visible for tests. + */ + static int decayed(int stock, int amount) { + if (stock > 0) { + return Math.max(0, stock - amount); + } + return Math.min(0, stock + amount); + } + + public void saveAll() { + cache.values().forEach(handler::saveObjectAsync); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java new file mode 100644 index 0000000..7925411 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java @@ -0,0 +1,72 @@ +package world.bentobox.tradewinds.dataobjects; + +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.annotations.Expose; + +import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.database.objects.Table; + +/** + * Per-trading-island market state, keyed by galaxy cell ("cellX,cellZ"): + * stock per trade category (relative to equilibrium 0) and the last decay + * timestamp. This is what makes markets remember being flooded or bought out + * across restarts (spec §9). + * + * @author tastybento + */ +@Table(name = "TWIslandData") +public class TWIslandData implements DataObject { + + @Expose + private String uniqueId; + + /** + * Stock per trade category name. Positive = players sold a lot here + * (prices depressed); negative = bought out (prices raised). + */ + @Expose + private Map stock = new HashMap<>(); + + /** + * Epoch millis of the last stock decay pass. + */ + @Expose + private long lastDecay; + + public TWIslandData() { + // Required by the database + } + + public TWIslandData(String uniqueId) { + this.uniqueId = uniqueId; + this.lastDecay = System.currentTimeMillis(); + } + + @Override + public String getUniqueId() { + return uniqueId; + } + + @Override + public void setUniqueId(String uniqueId) { + this.uniqueId = uniqueId; + } + + public Map getStock() { + return stock; + } + + public void setStock(Map stock) { + this.stock = stock; + } + + public long getLastDecay() { + return lastDecay; + } + + public void setLastDecay(long lastDecay) { + this.lastDecay = lastDecay; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index 1968364..a1bc54e 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -34,6 +34,12 @@ public class TWPlayerData implements DataObject { @Expose private boolean starterKitGiven; + /** + * Cargo expanders bought so far - drives the doubling price and the cap. + */ + @Expose + private int expandersPurchased; + public TWPlayerData() { // Required by the database } @@ -74,6 +80,14 @@ public void setUniqueId(String uniqueId) { this.uniqueId = uniqueId; } + public int getExpandersPurchased() { + return expandersPurchased; + } + + public void setExpandersPurchased(int expandersPurchased) { + this.expandersPurchased = expandersPurchased; + } + public boolean isStarterKitGiven() { return starterKitGiven; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java new file mode 100644 index 0000000..77b2f10 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -0,0 +1,241 @@ +package world.bentobox.tradewinds.economy; + +import java.lang.reflect.Method; +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.hooks.VaultHook; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.TWTradeEvent; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * The market: prices and trades. Base prices come from BlueBook when it is + * installed (looked up reflectively - soft dependency), else from the + * fallback table in config. Island prices apply the type/band/stock model + * ({@link PriceModel}); all transactions move items through the hold ONLY and + * money through Vault. + * + * @author tastybento + */ +public class MarketService { + + /** PDC key marking cargo expander items. */ + public static final NamespacedKey EXPANDER_KEY = NamespacedKey.fromString("tradewinds:expander"); + + private final TradeWinds addon; + private Object priceEngine; + private Method getPriceMethod; + private boolean blueBookChecked; + + public MarketService(TradeWinds addon) { + this.addon = addon; + } + + /** + * The configured price model. + */ + public PriceModel model() { + var s = addon.getSettings(); + return new PriceModel(s.getProduceFactor(), s.getDemandFactor(), s.getBandDemandBonus(), s.getBuySpread(), + s.getSellSpread(), s.getDriftScale(), s.getDriftMin(), s.getDriftMax()); + } + + /** + * Base price of a material: BlueBook if present, else the fallback table. + * Empty means not tradeable. + */ + public Optional basePrice(Material material) { + Double blueBook = blueBookPrice(material); + if (blueBook != null && blueBook > 0) { + return Optional.of(blueBook); + } + Double fallback = addon.getSettings().getFallbackPrices().get(material.name()); + return fallback != null && fallback > 0 ? Optional.of(fallback) : Optional.empty(); + } + + /** + * What a player pays this island per unit, or empty if not tradeable. + */ + public Optional playerBuysAt(IslandSpec spec, Material material) { + return basePrice(material).map(base -> model().playerBuysAt(base, factor(spec, material))); + } + + /** + * What this island pays a player per unit, or empty if not tradeable. + */ + public Optional playerSellsAt(IslandSpec spec, Material material) { + return basePrice(material).map(base -> model().playerSellsAt(base, factor(spec, material))); + } + + private double factor(IslandSpec spec, Material material) { + TradeCategory category = TradeCategory.of(material); + return model().economicFactor(TypeEconomy.produces(spec.type()).contains(category), + TypeEconomy.demands(spec.type()).contains(category), spec.band().ordinal(), + addon.getIslandDataManager().getStock(spec, category)); + } + + /** + * Sell everything of one material from the player's hold to the island. + * + * @return amount sold + */ + public int sell(Player player, IslandSpec spec, Material material) { + Optional unitPrice = playerSellsAt(spec, material); + Optional vault = addon.getPlugin().getVault(); + if (unitPrice.isEmpty() || vault.isEmpty()) { + return 0; + } + int count = addon.getHoldService().count(player, material); + if (count <= 0) { + return 0; + } + TWTradeEvent event = new TWTradeEvent(player, spec, material, count, unitPrice.get(), true); + org.bukkit.Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return 0; + } + int removed = addon.getHoldService().remove(player, material, count); + double total = PriceModel.round2(removed * unitPrice.get()); + vault.get().deposit(User.getInstance(player), total); + addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), removed); + User.getInstance(player).sendMessage("tradewinds.trade.sold", "[amount]", String.valueOf(removed), + "[material]", pretty(material), "[price]", String.format("%.2f", total)); + return removed; + } + + /** + * Buy up to {@code amount} of a material from the island into the hold, + * limited by balance and hold space. + * + * @return amount bought + */ + public int buy(Player player, IslandSpec spec, Material material, int amount) { + Optional unitPrice = playerBuysAt(spec, material); + Optional vault = addon.getPlugin().getVault(); + if (unitPrice.isEmpty() || vault.isEmpty() || amount <= 0) { + return 0; + } + User user = User.getInstance(player); + // Limit by balance + double balance = vault.get().getBalance(user); + int affordable = (int) Math.min(amount, Math.floor(balance / unitPrice.get())); + if (affordable <= 0) { + user.sendMessage("tradewinds.trade.cannot-afford"); + return 0; + } + TWTradeEvent event = new TWTradeEvent(player, spec, material, affordable, unitPrice.get(), false); + org.bukkit.Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return 0; + } + // Limit by hold space: add first, pay for what fit + int added = addon.getHoldService().add(player, new ItemStack(material, affordable)); + if (added <= 0) { + user.sendMessage("tradewinds.trade.no-hold-space"); + return 0; + } + double total = PriceModel.round2(added * unitPrice.get()); + vault.get().withdraw(user, total); + addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), -added); + user.sendMessage("tradewinds.trade.bought", "[amount]", String.valueOf(added), "[material]", + pretty(material), "[price]", String.format("%.2f", total)); + return added; + } + + /** + * Buy a cargo expander: price doubles per expander already owned, capped. + * + * @return true if bought + */ + public boolean buyExpander(Player player) { + Optional vault = addon.getPlugin().getVault(); + if (vault.isEmpty()) { + return false; + } + User user = User.getInstance(player); + int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); + if (owned >= addon.getSettings().getExpanderCap()) { + user.sendMessage("tradewinds.trade.expander-cap"); + return false; + } + double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); + if (!vault.get().has(user, price)) { + user.sendMessage("tradewinds.trade.cannot-afford"); + return false; + } + // Must go into the hold (the chest boat) - expanders never ride pockets + int added = addon.getHoldService().add(player, expanderItem()); + if (added <= 0) { + user.sendMessage("tradewinds.trade.no-hold-space"); + return false; + } + vault.get().withdraw(user, price); + var data = addon.getPlayerDataManager().get(player.getUniqueId()); + data.setExpandersPurchased(owned + 1); + addon.getPlayerDataManager().save(player.getUniqueId()); + user.sendMessage("tradewinds.trade.expander-bought", "[price]", String.format("%.2f", price)); + return true; + } + + /** + * The cargo expander item: a lore-marked shulker box. Purchase-only - with + * no End there are no shulker shells to craft one (spec principle 7). + */ + public ItemStack expanderItem() { + ItemStack item = new ItemStack(Material.SHULKER_BOX); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.displayName(Component.text("Cargo Expander", NamedTextColor.GOLD)); + meta.lore(java.util.List.of(Component.text("Expands your boat's hold.", NamedTextColor.GRAY), + Component.text("Stow in a chest boat.", NamedTextColor.GRAY))); + meta.getPersistentDataContainer().set(EXPANDER_KEY, PersistentDataType.STRING, "expander"); + item.setItemMeta(meta); + } + return item; + } + + /** + * Reflective BlueBook lookup: PriceEngine.getPrice(ItemStack, worldName). + * Soft dependency - absent or failing BlueBook just means fallback prices. + */ + private Double blueBookPrice(Material material) { + if (!blueBookChecked) { + blueBookChecked = true; + addon.getPlugin().getAddonsManager().getAddonByName("BlueBook").ifPresent(bb -> { + try { + Object engine = bb.getClass().getMethod("getPriceEngine").invoke(bb); + priceEngine = engine; + getPriceMethod = engine.getClass().getMethod("getPrice", ItemStack.class, String.class); + addon.log("Using BlueBook for base prices"); + } catch (ReflectiveOperationException e) { + addon.logError("BlueBook found but its price API is unavailable: " + e.getMessage()); + } + }); + } + if (priceEngine == null || getPriceMethod == null) { + return null; + } + try { + Object price = getPriceMethod.invoke(priceEngine, new ItemStack(material), + addon.getSettings().getWorldName()); + return price instanceof Number number ? number.doubleValue() : null; + } catch (ReflectiveOperationException e) { + return null; + } + } + + static String pretty(Material material) { + String name = material.name().toLowerCase(java.util.Locale.ENGLISH).replace('_', ' '); + return Character.toUpperCase(name.charAt(0)) + name.substring(1); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java b/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java new file mode 100644 index 0000000..fbe4fb1 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java @@ -0,0 +1,80 @@ +package world.bentobox.tradewinds.economy; + +/** + * Pure price math. An island's price for a good is: + *

base x economicFactor x spread
+ * where the economic factor combines the type affinity (produce cheap, demand + * dear - demand amplified by the security band: margins scale with danger) + * and the stock drift (players selling into an island depress its prices; + * buying it out raises them). The buy/sell spread means a same-island round + * trip always loses money. + * + * @param produceFactor price multiplier where the island produces the category + * @param demandFactor price multiplier where the island demands the category + * @param bandDemandBonus extra demand multiplier per security band step + * @param buySpread multiplier on what players pay the island + * @param sellSpread multiplier on what the island pays players + * @param driftScale stock units for full drift swing + * @param driftMin lower clamp of the drift factor + * @param driftMax upper clamp of the drift factor + * + * @author tastybento + */ +public record PriceModel(double produceFactor, double demandFactor, double bandDemandBonus, double buySpread, + double sellSpread, int driftScale, double driftMin, double driftMax) { + + /** + * Stock drift: positive stock (players sold a lot here) depresses prices, + * negative stock (players bought the island out) raises them. + * + * @param stock current stock relative to equilibrium 0 + * @return clamped drift factor + */ + public double driftFactor(int stock) { + return Math.clamp(1.0 - (double) stock / driftScale, driftMin, driftMax); + } + + /** + * The island's economic factor for a category. + * + * @param produces the island produces this category + * @param demands the island demands this category + * @param bandOrdinal security band ordinal (0 = SAFE) + * @param stock current stock + * @return combined price factor + */ + public double economicFactor(boolean produces, boolean demands, int bandOrdinal, int stock) { + double factor = 1.0; + if (produces) { + factor = produceFactor; + } else if (demands) { + factor = demandFactor * (1.0 + bandDemandBonus * bandOrdinal); + } + return factor * driftFactor(stock); + } + + /** + * What a player pays the island per unit. + */ + public double playerBuysAt(double base, double economicFactor) { + return round2(base * economicFactor * buySpread); + } + + /** + * What the island pays a player per unit. + */ + public double playerSellsAt(double base, double economicFactor) { + return round2(base * economicFactor * sellSpread); + } + + /** + * Cargo expander price: doubles with each one owned. + */ + public static double expanderPrice(double basePrice, int owned) { + return basePrice * Math.pow(2, owned); + } + + static double round2(double value) { + return Math.round(value * 100.0) / 100.0; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java new file mode 100644 index 0000000..1055135 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java @@ -0,0 +1,68 @@ +package world.bentobox.tradewinds.economy; + +import java.util.Locale; + +import org.bukkit.Material; + +/** + * Coarse trade categories. Island types produce some categories (sell cheap) + * and demand others (buy dear) - the gradient between islands is what makes a + * route profitable. + * + * @author tastybento + */ +public enum TradeCategory { + CROPS, FOOD, FISH, WOOD, STONE, ORES, METALS, GEMS, LUXURY, MISC; + + /** + * Classify a material by name heuristics. MISC means "no special island + * affinity" - still tradeable at base price everywhere. + */ + public static TradeCategory of(Material material) { + String name = material.name().toLowerCase(Locale.ENGLISH); + if (name.contains("cod") || name.contains("salmon") || name.contains("tropical_fish") + || name.contains("pufferfish") || name.equals("kelp") || name.contains("nautilus")) { + return FISH; + } + if (name.contains("wheat") || name.contains("carrot") || name.contains("potato") + || name.contains("beetroot") || name.contains("melon") || name.contains("pumpkin") + || name.contains("sugar_cane") || name.contains("cocoa") || name.contains("seeds") + || name.contains("hay_block")) { + return CROPS; + } + if (name.contains("bread") || name.contains("cooked") || name.contains("beef") || name.contains("porkchop") + || name.contains("chicken") || name.contains("mutton") || name.contains("rabbit") + || name.contains("egg") && !name.contains("spawn") || name.contains("milk") + || name.contains("stew") || name.contains("soup") || name.contains("sugar")) { + return FOOD; + } + if (name.endsWith("_log") || name.endsWith("_planks") || name.endsWith("_wood") || name.contains("stripped") + || name.contains("sapling") || name.equals("stick") || name.contains("bamboo")) { + return WOOD; + } + if (name.contains("raw_") || name.endsWith("_ore") || name.equals("coal") || name.equals("charcoal") + || name.contains("flint")) { + return ORES; + } + if (name.endsWith("_ingot") || name.endsWith("_block") && (name.contains("iron") || name.contains("copper") + || name.contains("gold")) || name.contains("nugget") || name.contains("anvil") + || name.contains("chain")) { + return METALS; + } + if (name.contains("diamond") || name.contains("emerald") || name.contains("amethyst") + || name.contains("quartz") || name.contains("lapis")) { + return GEMS; + } + if (name.contains("cake") || name.contains("golden_apple") || name.contains("firework") + || name.contains("music_disc") || name.contains("candle") || name.contains("banner_pattern") + || name.contains("glazed")) { + return LUXURY; + } + if (name.contains("stone") || name.contains("granite") || name.contains("diorite") + || name.contains("andesite") || name.contains("deepslate") || name.contains("tuff") + || name.contains("gravel") || name.equals("sand") || name.contains("sandstone")) { + return STONE; + } + return MISC; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java new file mode 100644 index 0000000..7ef58b0 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -0,0 +1,145 @@ +package world.bentobox.tradewinds.economy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.entity.Player; + +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.body.DialogBody; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * The market dialogs: a main menu (sell / buy / shipwright), a sell page + * listing the hold's sellable cargo at this island's prices, and a buy page + * listing the island's catalog. Everything transacts through MarketService, + * hold-only; dialogs reopen after each action with fresh prices. + * + * @author tastybento + */ +public class TradeDialog { + + private static final int BUY_BATCH = 16; + + private final TradeWinds addon; + + public TradeDialog(TradeWinds addon) { + this.addon = addon; + } + + /** + * Open the market's main menu. + */ + public void openMain(Player player, IslandSpec spec) { + double balance = addon.getPlugin().getVault().map(v -> v.getBalance(User.getInstance(player))).orElse(0.0); + List buttons = new ArrayList<>(); + buttons.add(button("Sell cargo", NamedTextColor.YELLOW, "Sell your hold's goods here", + () -> openSell(player, spec))); + if (!TypeEconomy.catalog(spec.type()).isEmpty()) { + buttons.add(button("Buy goods", NamedTextColor.AQUA, "Buy this island's produce into your hold", + () -> openBuy(player, spec))); + } + int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); + if (owned < addon.getSettings().getExpanderCap()) { + double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); + buttons.add(button(String.format("Shipwright: Cargo Expander - $%.0f", price), NamedTextColor.GOLD, + "Shulker hold expansion (" + owned + "/" + addon.getSettings().getExpanderCap() + + " owned). Price doubles each time.", + () -> { + addon.getMarketService().buyExpander(player); + openMain(player, spec); + })); + } + show(player, spec.name() + " Market", "Balance: $" + String.format("%.2f", balance) + " | " + + spec.type().name() + ", " + spec.band().getDisplayName(), buttons); + } + + /** + * The sell page: one button per hold material this island will pay for. + */ + public void openSell(Player player, IslandSpec spec) { + List buttons = new ArrayList<>(); + for (SellOffer offer : sellOffers(player, spec)) { + buttons.add(button( + String.format("Sell %d x %s - $%.2f", offer.amount(), MarketService.pretty(offer.material()), + offer.total()), + NamedTextColor.YELLOW, String.format("$%.2f each", offer.unitPrice()), + () -> { + addon.getMarketService().sell(player, spec, offer.material()); + openSell(player, spec); + })); + } + if (buttons.isEmpty()) { + User.getInstance(player).sendMessage("tradewinds.trade.nothing-to-sell"); + return; + } + show(player, spec.name() + " - Selling", "The island pays for goods in your hold", buttons); + } + + /** + * The buy page: the island's catalog in batches. + */ + public void openBuy(Player player, IslandSpec spec) { + List buttons = new ArrayList<>(); + for (Material material : TypeEconomy.catalog(spec.type())) { + Optional price = addon.getMarketService().playerBuysAt(spec, material); + price.ifPresent(unit -> buttons.add(button( + String.format("Buy %d x %s - $%.2f", BUY_BATCH, MarketService.pretty(material), + unit * BUY_BATCH), + NamedTextColor.AQUA, String.format("$%.2f each; limited by balance and hold space", unit), + () -> { + addon.getMarketService().buy(player, spec, material, BUY_BATCH); + openBuy(player, spec); + }))); + } + if (buttons.isEmpty()) { + User.getInstance(player).sendMessage("tradewinds.trade.nothing-for-sale"); + return; + } + show(player, spec.name() + " - Buying", "Goods sold into your hold", buttons); + } + + /** + * One sellable line: what the hold has and what this island pays. Package + * visible for tests. + */ + record SellOffer(Material material, int amount, double unitPrice, double total) { + } + + List sellOffers(Player player, IslandSpec spec) { + List offers = new ArrayList<>(); + for (Map.Entry entry : addon.getHoldService().contents(player).entrySet()) { + addon.getMarketService().playerSellsAt(spec, entry.getKey()) + .ifPresent(unit -> offers.add(new SellOffer(entry.getKey(), entry.getValue(), unit, + PriceModel.round2(unit * entry.getValue())))); + } + return offers; + } + + private ActionButton button(String label, NamedTextColor color, String tooltip, Runnable action) { + return ActionButton.create(Component.text(label, color), Component.text(tooltip), 300, + DialogAction.customClick((response, audience) -> action.run(), + ClickCallback.Options.builder().build())); + } + + private void show(Player player, String title, String body, List buttons) { + Dialog dialog = Dialog.create(factory -> factory.empty() + .base(DialogBase.builder(Component.text(title)) + .body(List.of(DialogBody.plainMessage(Component.text(body, NamedTextColor.GRAY)))) + .build()) + .type(DialogType.multiAction(buttons).columns(1).build())); + player.showDialog(dialog); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeListener.java b/src/main/java/world/bentobox/tradewinds/economy/TradeListener.java new file mode 100644 index 0000000..ba45ce2 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeListener.java @@ -0,0 +1,43 @@ +package world.bentobox.tradewinds.economy; + +import java.util.Optional; + +import org.bukkit.entity.Player; +import org.bukkit.entity.Villager; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.persistence.PersistentDataType; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.generator.IslandDecorator; + +/** + * Right-clicking a resident trader opens the island's market instead of the + * (empty) vanilla trade screen. + * + * @author tastybento + */ +public class TradeListener implements Listener { + + private final TradeWinds addon; + + public TradeListener(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onTraderClick(PlayerInteractEntityEvent event) { + if (!(event.getRightClicked() instanceof Villager villager) || !villager.getPersistentDataContainer() + .has(IslandDecorator.RESIDENT_KEY, PersistentDataType.STRING)) { + return; + } + event.setCancelled(true); + Player player = event.getPlayer(); + Optional spec = addon.getGalaxyEngine(addon.getOverWorld().getSeed()) + .islandAt(villager.getLocation().getBlockX(), villager.getLocation().getBlockZ()); + spec.ifPresent(island -> addon.getTradeDialog().openMain(player, island)); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java new file mode 100644 index 0000000..df4fc58 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java @@ -0,0 +1,75 @@ +package world.bentobox.tradewinds.economy; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Material; + +import world.bentobox.tradewinds.galaxy.IslandType; + +/** + * What each island type produces (sells cheap) and demands (buys dear), and + * the catalog of goods a producing island offers for sale. The gradient is the + * game: haul wood from FOREST to AGRICULTURAL, ore from MINING to INDUSTRIAL, + * luxuries to LUXURY resorts in dangerous space. + * + * @author tastybento + */ +public final class TypeEconomy { + + private static final Map> PRODUCES = Map.of( + IslandType.AGRICULTURAL, Set.of(TradeCategory.CROPS, TradeCategory.FOOD), + IslandType.FOREST, Set.of(TradeCategory.WOOD), + IslandType.FISHING, Set.of(TradeCategory.FISH), + IslandType.MINING, Set.of(TradeCategory.STONE, TradeCategory.ORES), + IslandType.INDUSTRIAL, Set.of(TradeCategory.METALS), + IslandType.LUXURY, Set.of(), + IslandType.FROZEN, Set.of(TradeCategory.FISH)); + + private static final Map> DEMANDS = Map.of( + IslandType.AGRICULTURAL, Set.of(TradeCategory.METALS, TradeCategory.WOOD), + IslandType.FOREST, Set.of(TradeCategory.FOOD, TradeCategory.METALS), + IslandType.FISHING, Set.of(TradeCategory.WOOD, TradeCategory.CROPS), + IslandType.MINING, Set.of(TradeCategory.FOOD, TradeCategory.WOOD), + IslandType.INDUSTRIAL, Set.of(TradeCategory.ORES, TradeCategory.STONE), + IslandType.LUXURY, Set.of(TradeCategory.LUXURY, TradeCategory.GEMS, TradeCategory.FISH), + IslandType.FROZEN, Set.of(TradeCategory.FOOD, TradeCategory.WOOD)); + + /** + * Sale catalog per category - the goods a producing island offers. + * Content list, not balance numbers; can move to config when servers want + * custom goods. + */ + private static final Map> CATALOG = Map.of( + TradeCategory.CROPS, List.of(Material.WHEAT, Material.CARROT, Material.POTATO, Material.SUGAR_CANE, + Material.PUMPKIN), + TradeCategory.FOOD, List.of(Material.BREAD, Material.COOKED_BEEF, Material.COOKED_COD), + TradeCategory.FISH, List.of(Material.COD, Material.SALMON, Material.TROPICAL_FISH), + TradeCategory.WOOD, List.of(Material.OAK_LOG, Material.SPRUCE_LOG, Material.DARK_OAK_LOG), + TradeCategory.STONE, List.of(Material.STONE, Material.GRANITE, Material.DEEPSLATE), + TradeCategory.ORES, List.of(Material.COAL, Material.RAW_IRON, Material.RAW_COPPER, Material.RAW_GOLD), + TradeCategory.METALS, List.of(Material.IRON_INGOT, Material.COPPER_INGOT, Material.GOLD_INGOT), + TradeCategory.GEMS, List.of(Material.DIAMOND, Material.EMERALD, Material.AMETHYST_SHARD), + TradeCategory.LUXURY, List.of(Material.CAKE, Material.GOLDEN_APPLE), + TradeCategory.MISC, List.of()); + + private TypeEconomy() { + // Static use only + } + + public static Set produces(IslandType type) { + return PRODUCES.get(type); + } + + public static Set demands(IslandType type) { + return DEMANDS.get(type); + } + + /** + * The goods an island of this type offers for sale. + */ + public static List catalog(IslandType type) { + return produces(type).stream().flatMap(cat -> CATALOG.get(cat).stream()).toList(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java index cde9159..7600107 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -95,5 +95,9 @@ public void applyBandFlags(Island island, IslandSpec spec) { island.setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, addon.getSettings().getBandMonsterSpawn().getOrDefault(band, true)); island.setFlag(Flags.HURT_VILLAGERS, addon.getSettings().getBandHurtVillagersRank().getOrDefault(band, 0)); + // Everyone may drop and pick up items on trading islands - trade, + // jettisoned cargo, and plain convenience all depend on it + island.setFlag(Flags.ITEM_DROP, 0); + island.setFlag(Flags.ITEM_PICKUP, 0); } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java new file mode 100644 index 0000000..78c91a3 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -0,0 +1,279 @@ +package world.bentobox.tradewinds.travel; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.bukkit.Material; +import org.bukkit.block.ShulkerBox; +import org.bukkit.entity.ChestBoat; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BlockStateMeta; +import org.bukkit.inventory.meta.BundleMeta; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * The hold: the ONLY container trade transacts against (spec principle 1). + * It is the chest boat's inventory, the contents of cargo-expander shulker + * boxes stored in that inventory, and up to max-bundles trading bundles in + * the player's own inventory. Pocket items are invisible to the market. + *

+ * The cargo progression IS this definition: bundle(s) -> chest boat -> + * expanders in the chest boat. + * + * @author tastybento + */ +public class HoldService { + + private final TradeWinds addon; + + public HoldService(TradeWinds addon) { + this.addon = addon; + } + + /** + * Everything sellable in the hold, aggregated by material, in encounter + * order. + */ + public Map contents(Player player) { + Map result = new LinkedHashMap<>(); + forEachHoldStack(player, stack -> result.merge(stack.getType(), stack.getAmount(), Integer::sum)); + return result; + } + + /** + * How many of a material the hold contains. + */ + public int count(Player player, Material material) { + return contents(player).getOrDefault(material, 0); + } + + /** + * Remove up to {@code amount} of a material from the hold (chest boat + * first, then expanders, then bundles). + * + * @return how many were actually removed + */ + public int remove(Player player, Material material, int amount) { + int[] left = { amount }; + if (player.getVehicle() instanceof ChestBoat boat) { + Inventory inv = boat.getInventory(); + for (ItemStack stack : inv.getContents()) { + if (left[0] <= 0) { + break; + } + if (stack == null) { + continue; + } + if (stack.getType() == material) { + int take = Math.min(left[0], stack.getAmount()); + stack.setAmount(stack.getAmount() - take); + if (stack.getAmount() <= 0) { + inv.remove(stack); + } + left[0] -= take; + } else if (isExpander(stack)) { + left[0] -= removeFromShulker(stack, material, left[0]); + } + } + } + for (ItemStack bundleItem : bundles(player)) { + if (left[0] <= 0) { + break; + } + left[0] -= removeFromBundle(bundleItem, material, left[0]); + } + return amount - left[0]; + } + + /** + * Add items to the hold: chest boat first, then a cargo expander with + * room, then a bundle with room. + * + * @return how many items were actually added (0 if no space) + */ + public int add(Player player, ItemStack items) { + int remaining = items.getAmount(); + if (player.getVehicle() instanceof ChestBoat boat) { + ItemStack toAdd = items.clone(); + toAdd.setAmount(remaining); + Map leftover = boat.getInventory().addItem(toAdd); + remaining = leftover.values().stream().mapToInt(ItemStack::getAmount).sum(); + if (remaining <= 0) { + return items.getAmount(); + } + // Try expanders in the boat + for (ItemStack stack : boat.getInventory().getContents()) { + if (remaining <= 0) { + break; + } + if (isExpander(stack)) { + remaining -= addToShulker(stack, items, remaining); + } + } + } + // Bundles: only simple stackables, vanilla capacity 64 + for (ItemStack bundleItem : bundles(player)) { + if (remaining <= 0) { + break; + } + remaining -= addToBundle(bundleItem, items, remaining); + } + return items.getAmount() - remaining; + } + + /** + * Is this item a TradeWinds cargo expander (lore-renamed shulker box)? + */ + public boolean isExpander(ItemStack stack) { + return stack != null && stack.getType() == Material.SHULKER_BOX && stack.hasItemMeta() + && stack.getItemMeta().getPersistentDataContainer().has( + org.bukkit.NamespacedKey.fromString("tradewinds:expander"), + org.bukkit.persistence.PersistentDataType.STRING); + } + + /** + * The player's trading bundles, capped at the configured maximum - a + * fourth bundle is just a bag, not hold space. + */ + private List bundles(Player player) { + List result = new ArrayList<>(); + int max = addon.getSettings().getMaxBundles(); + for (ItemStack stack : player.getInventory().getContents()) { + if (result.size() >= max) { + break; + } + if (stack != null && stack.getItemMeta() instanceof BundleMeta) { + result.add(stack); + } + } + return result; + } + + private void forEachHoldStack(Player player, java.util.function.Consumer consumer) { + if (player.getVehicle() instanceof ChestBoat boat) { + for (ItemStack stack : boat.getInventory().getContents()) { + if (stack == null || stack.getType().isAir()) { + continue; + } + if (isExpander(stack)) { + shulkerContents(stack).forEach(consumer); + } else { + consumer.accept(stack); + } + } + } + for (ItemStack bundleItem : bundles(player)) { + if (bundleItem.getItemMeta() instanceof BundleMeta bundle) { + bundle.getItems().stream().filter(i -> i != null && !i.getType().isAir()).forEach(consumer); + } + } + } + + private List shulkerContents(ItemStack expander) { + if (expander.getItemMeta() instanceof BlockStateMeta meta && meta.getBlockState() instanceof ShulkerBox box) { + List list = new ArrayList<>(); + for (ItemStack stack : box.getInventory().getContents()) { + if (stack != null && !stack.getType().isAir()) { + list.add(stack); + } + } + return list; + } + return List.of(); + } + + private int removeFromShulker(ItemStack expander, Material material, int amount) { + if (!(expander.getItemMeta() instanceof BlockStateMeta meta) + || !(meta.getBlockState() instanceof ShulkerBox box)) { + return 0; + } + int removed = 0; + for (ItemStack stack : box.getInventory().getContents()) { + if (removed >= amount) { + break; + } + if (stack != null && stack.getType() == material) { + int take = Math.min(amount - removed, stack.getAmount()); + stack.setAmount(stack.getAmount() - take); + if (stack.getAmount() <= 0) { + box.getInventory().remove(stack); + } + removed += take; + } + } + if (removed > 0) { + meta.setBlockState(box); + expander.setItemMeta(meta); + } + return removed; + } + + private int addToShulker(ItemStack expander, ItemStack items, int amount) { + if (!(expander.getItemMeta() instanceof BlockStateMeta meta) + || !(meta.getBlockState() instanceof ShulkerBox box)) { + return 0; + } + ItemStack toAdd = items.clone(); + toAdd.setAmount(amount); + Map leftover = box.getInventory().addItem(toAdd); + int added = amount - leftover.values().stream().mapToInt(ItemStack::getAmount).sum(); + if (added > 0) { + meta.setBlockState(box); + expander.setItemMeta(meta); + } + return added; + } + + private int removeFromBundle(ItemStack bundleItem, Material material, int amount) { + if (!(bundleItem.getItemMeta() instanceof BundleMeta bundle)) { + return 0; + } + int removed = 0; + List items = new ArrayList<>(bundle.getItems()); + List kept = new ArrayList<>(); + for (ItemStack stack : items) { + if (removed < amount && stack.getType() == material) { + int take = Math.min(amount - removed, stack.getAmount()); + removed += take; + if (stack.getAmount() > take) { + ItemStack rest = stack.clone(); + rest.setAmount(stack.getAmount() - take); + kept.add(rest); + } + } else { + kept.add(stack); + } + } + if (removed > 0) { + bundle.setItems(kept); + bundleItem.setItemMeta(bundle); + } + return removed; + } + + private int addToBundle(ItemStack bundleItem, ItemStack items, int amount) { + if (!(bundleItem.getItemMeta() instanceof BundleMeta bundle) || items.getMaxStackSize() < 64) { + // Vanilla bundle weight rules for oddball items are not worth + // emulating - only plain stackables ride in bundles + return 0; + } + int used = bundle.getItems().stream().mapToInt(ItemStack::getAmount).sum(); + int space = 64 - used; + int toAdd = Math.min(space, amount); + if (toAdd <= 0) { + return 0; + } + List newItems = new ArrayList<>(bundle.getItems()); + ItemStack adding = items.clone(); + adding.setAmount(toAdd); + newItems.add(adding); + bundle.setItems(newItems); + bundleItem.setItemMeta(bundle); + return toAdd; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java index a4864c5..04d7085 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java @@ -54,6 +54,10 @@ public void onSpawnArrival(Player player) { } else { player.getInventory().addItem(new ItemStack(Material.OAK_BOAT)); } + // Seed money for the first cargo + addon.getPlugin().getVault() + .ifPresent(vault -> vault.deposit(User.getInstance(player), + addon.getSettings().getStartingBalance())); User.getInstance(player).sendMessage("tradewinds.starter-kit.given"); return; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b3f8ed4..03efc4d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -113,6 +113,57 @@ residents: hud: # Navigation boss bar in island waters: island name, standing, distance to dock. navigation-bossbar: true +economy: + # Money given to brand-new players with their starter kit. + starting-balance: 250.0 + # Multiplier on what players PAY an island (buying) / what an island PAYS players (selling). + # Together these make a same-island round trip always lose money. + buy-spread: 1.15 + sell-spread: 0.85 + # Price multiplier for categories an island produces (its own goods are cheap) + # and demands (it pays over the odds). + produce-factor: 0.6 + demand-factor: 1.4 + # Extra demand multiplier per security band step - margins scale with danger. + band-demand-bonus: 0.125 + # Stock units for a full price swing; drift clamps; hourly decay toward equilibrium. + drift-scale: 500 + drift-min: 0.7 + drift-max: 1.3 + stock-decay-per-hour: 50 + # Cargo expanders: first price, doubling each purchase, hard cap per player. + expander-base-price: 5000.0 + expander-cap: 4 + # Maximum trading bundles that count as hold space. + max-bundles: 3 + # Base prices used when BlueBook is not installed (Material -> price). + fallback-prices: + WHEAT: 2.0 + CARROT: 1.5 + POTATO: 1.5 + SUGAR_CANE: 1.0 + SUGAR: 1.5 + BREAD: 3.0 + COOKED_BEEF: 4.0 + COD: 2.0 + SALMON: 3.0 + OAK_LOG: 1.5 + SPRUCE_LOG: 1.5 + DARK_OAK_LOG: 1.5 + STONE: 0.5 + COAL: 4.0 + RAW_IRON: 6.0 + RAW_COPPER: 3.0 + RAW_GOLD: 12.0 + IRON_INGOT: 9.0 + COPPER_INGOT: 4.0 + GOLD_INGOT: 18.0 + DIAMOND: 100.0 + EMERALD: 60.0 + AMETHYST_SHARD: 10.0 + CAKE: 20.0 + GOLDEN_APPLE: 150.0 + # (the full default table ships in code; add or override entries here) illegal-trade: # Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling. # Set false for family-friendly servers - removes the entire crime layer cleanly. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 81567bc..2a8f02a 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -18,6 +18,8 @@ tradewinds: description: "open the warp dialog (in a boat, at an island)" chart: description: "list the islands on your chart" + trade: + description: "open the island market (within its protection range)" admin: help: description: "admin command for TradeWinds" @@ -45,6 +47,16 @@ tradewinds: empty: "&c Your chart is empty - row into an island's waters to chart it." header: "&b Your chart ([number] islands):" entry: "&e [name] &7- [type], [band], [distance] blocks" + trade: + not-at-market: "&c There is no market here - trade within an island's protection range." + nothing-to-sell: "&c Nothing in your hold that this island will pay for." + nothing-for-sale: "&c This island has nothing for sale - it only buys." + sold: "&a Sold [amount] x [material] for $[price]." + bought: "&a Bought [amount] x [material] for $[price]." + cannot-afford: "&c You cannot afford that." + no-hold-space: "&c No room in your hold - stow cargo in your chest boat, expanders or bundles." + expander-bought: "&6 Cargo expander purchased for $[price] - stowed in your hold." + expander-cap: "&c Your boat cannot take any more expanders." warp: not-in-boat: "&c You need to be aboard a boat to warp." not-at-island: "&c The warp only engages within an island's waters." diff --git a/src/test/java/world/bentobox/tradewinds/dataobjects/IslandDataManagerTest.java b/src/test/java/world/bentobox/tradewinds/dataobjects/IslandDataManagerTest.java new file mode 100644 index 0000000..effea18 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/dataobjects/IslandDataManagerTest.java @@ -0,0 +1,25 @@ +package world.bentobox.tradewinds.dataobjects; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Tests the stock decay math: markets trend back to equilibrium. + * + * @author tastybento + */ +class IslandDataManagerTest { + + @Test + void testDecayTowardZero() { + // Glut decays down + assertEquals(150, IslandDataManager.decayed(200, 50)); + assertEquals(0, IslandDataManager.decayed(30, 50)); + // Shortage decays up + assertEquals(-150, IslandDataManager.decayed(-200, 50)); + assertEquals(0, IslandDataManager.decayed(-30, 50)); + // Equilibrium stays put + assertEquals(0, IslandDataManager.decayed(0, 50)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java b/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java new file mode 100644 index 0000000..5038194 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java @@ -0,0 +1,64 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Pure tests of the price model: the invariants that make trading a game. + * + * @author tastybento + */ +class PriceModelTest { + + private final PriceModel model = new PriceModel(0.6, 1.4, 0.125, 1.15, 0.85, 500, 0.7, 1.3); + + @Test + void testProfitableRouteExists() { + // Buy CROPS at an AGRICULTURAL island (produces), sell at a FISHING island (demands) + double buyAt = model.playerBuysAt(10.0, model.economicFactor(true, false, 0, 0)); + double sellAt = model.playerSellsAt(10.0, model.economicFactor(false, true, 0, 0)); + assertTrue(sellAt > buyAt, "Produce->demand route must be profitable: buy " + buyAt + " sell " + sellAt); + // 10.0 * 0.6 * 1.15 = 6.90 ; 10.0 * 1.4 * 0.85 = 11.90 + assertEquals(6.90, buyAt); + assertEquals(11.90, sellAt); + } + + @Test + void testSameIslandRoundTripLosesMoney() { + for (boolean produces : new boolean[] { true, false }) { + double factor = model.economicFactor(produces, !produces, 2, 0); + assertTrue(model.playerSellsAt(10.0, factor) < model.playerBuysAt(10.0, factor), + "Same-island arbitrage must be impossible"); + } + } + + @Test + void testMarginsScaleWithDanger() { + // The same demanded good pays better in dangerous space + double safe = model.playerSellsAt(10.0, model.economicFactor(false, true, 0, 0)); + double anarchic = model.playerSellsAt(10.0, model.economicFactor(false, true, 4, 0)); + assertTrue(anarchic > safe); + // ANARCHIC pays 1.5x the SAFE demand price at defaults + assertEquals(safe * 1.5, anarchic, 0.01); + } + + @Test + void testStockDrift() { + // Flooding an island with goods depresses its prices + assertEquals(1.0, model.driftFactor(0)); + assertTrue(model.driftFactor(250) < 1.0); + assertEquals(0.7, model.driftFactor(5000)); // clamped + // Buying it out raises them + assertTrue(model.driftFactor(-250) > 1.0); + assertEquals(1.3, model.driftFactor(-5000)); // clamped + } + + @Test + void testExpanderPricingDoubles() { + assertEquals(5000.0, PriceModel.expanderPrice(5000, 0)); + assertEquals(10000.0, PriceModel.expanderPrice(5000, 1)); + assertEquals(40000.0, PriceModel.expanderPrice(5000, 3)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java b/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java new file mode 100644 index 0000000..b3e9f06 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java @@ -0,0 +1,77 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.galaxy.IslandType; + +/** + * Tests category classification and the type economy tables. + * + * @author tastybento + */ +class TradeCategoryTest extends CommonTestSetup { + + @Test + void testClassification() { + assertEquals(TradeCategory.CROPS, TradeCategory.of(Material.WHEAT)); + assertEquals(TradeCategory.FOOD, TradeCategory.of(Material.BREAD)); + assertEquals(TradeCategory.FOOD, TradeCategory.of(Material.SUGAR)); + assertEquals(TradeCategory.FISH, TradeCategory.of(Material.COD)); + assertEquals(TradeCategory.WOOD, TradeCategory.of(Material.OAK_LOG)); + assertEquals(TradeCategory.STONE, TradeCategory.of(Material.GRANITE)); + assertEquals(TradeCategory.ORES, TradeCategory.of(Material.RAW_IRON)); + assertEquals(TradeCategory.ORES, TradeCategory.of(Material.COAL)); + assertEquals(TradeCategory.METALS, TradeCategory.of(Material.IRON_INGOT)); + assertEquals(TradeCategory.GEMS, TradeCategory.of(Material.DIAMOND)); + assertEquals(TradeCategory.LUXURY, TradeCategory.of(Material.CAKE)); + assertEquals(TradeCategory.MISC, TradeCategory.of(Material.DIRT)); + } + + @Test + void testEveryTypeDemandsSomething() { + for (IslandType type : IslandType.values()) { + assertFalse(TypeEconomy.demands(type).isEmpty(), type + " must demand something"); + } + } + + @Test + void testProduceAndDemandNeverOverlap() { + for (IslandType type : IslandType.values()) { + TypeEconomy.produces(type) + .forEach(cat -> assertFalse(TypeEconomy.demands(type).contains(cat), + type + " both produces and demands " + cat)); + } + } + + @Test + void testProfitableRoutePairsExistBetweenTypes() { + // For every produced category somewhere, some other type demands it + for (IslandType producer : IslandType.values()) { + for (TradeCategory cat : TypeEconomy.produces(producer)) { + boolean demanded = false; + for (IslandType consumer : IslandType.values()) { + if (consumer != producer && TypeEconomy.demands(consumer).contains(cat)) { + demanded = true; + break; + } + } + assertTrue(demanded, cat + " produced by " + producer + " is demanded nowhere"); + } + } + } + + @Test + void testCatalogsMatchProduction() { + assertFalse(TypeEconomy.catalog(IslandType.AGRICULTURAL).isEmpty()); + assertTrue(TypeEconomy.catalog(IslandType.AGRICULTURAL).contains(Material.WHEAT)); + assertTrue(TypeEconomy.catalog(IslandType.MINING).contains(Material.RAW_IRON)); + // LUXURY produces nothing - it only buys + assertTrue(TypeEconomy.catalog(IslandType.LUXURY).isEmpty()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java new file mode 100644 index 0000000..efbe8aa --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java @@ -0,0 +1,95 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.bukkit.Material; +import org.bukkit.entity.ChestBoat; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +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.TradeWinds; + +/** + * Tests the hold: chest-boat contents count, pockets never do, removal takes + * from the boat, addition respects inventory space. + * + * @author tastybento + */ +class HoldServiceTest extends CommonTestSetup { + + private TradeWinds addon; + private HoldService service; + private Inventory boatInv; + private ItemStack wheat; + private ItemStack coal; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + when(addon.getSettings()).thenReturn(new Settings()); + service = new HoldService(addon); + + wheat = new ItemStack(Material.WHEAT, 40); + coal = new ItemStack(Material.COAL, 10); + ChestBoat boat = mock(ChestBoat.class); + boatInv = mock(Inventory.class); + when(boat.getInventory()).thenReturn(boatInv); + when(boatInv.getContents()).thenReturn(new ItemStack[] { wheat, coal, null }); + when(mockPlayer.getVehicle()).thenReturn(boat); + // Pockets full of diamonds that must never count + PlayerInventory pockets = mock(PlayerInventory.class); + when(pockets.getContents()).thenReturn(new ItemStack[] { new ItemStack(Material.DIAMOND, 64), null }); + when(mockPlayer.getInventory()).thenReturn(pockets); + } + + @Test + void testContentsAreHoldOnly() { + Map contents = service.contents(mockPlayer); + assertEquals(40, contents.get(Material.WHEAT)); + assertEquals(10, contents.get(Material.COAL)); + // Pocket diamonds are invisible to the market (spec principle 1) + assertEquals(0, service.count(mockPlayer, Material.DIAMOND)); + } + + @Test + void testNoBoatNoHold() { + when(mockPlayer.getVehicle()).thenReturn(null); + assertEquals(0, service.count(mockPlayer, Material.WHEAT)); + assertEquals(0, service.remove(mockPlayer, Material.WHEAT, 10)); + } + + @Test + void testRemove() { + assertEquals(25, service.remove(mockPlayer, Material.WHEAT, 25)); + assertEquals(15, wheat.getAmount()); + // Removing more than present takes what there is + assertEquals(15, service.remove(mockPlayer, Material.WHEAT, 99)); + } + + @Test + void testAddUsesBoatInventory() { + when(boatInv.addItem(org.mockito.ArgumentMatchers.any(ItemStack.class))).thenReturn(new HashMap<>()); + assertEquals(16, service.add(mockPlayer, new ItemStack(Material.STONE, 16))); + } + + @Test + void testAddReportsLeftover() { + // Boat rejects 10 of 16 + HashMap leftover = new HashMap<>(); + leftover.put(0, new ItemStack(Material.STONE, 10)); + when(boatInv.addItem(org.mockito.ArgumentMatchers.any(ItemStack.class))).thenReturn(leftover); + assertEquals(6, service.add(mockPlayer, new ItemStack(Material.STONE, 16))); + } +} From 1dcd766cfa3089b740e057db3a51891f7f4e657c Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 08:03:05 -0700 Subject: [PATCH 013/112] Trade UX (balance/space/back buttons) + embed BlueBook pricing engine - Every market screen shows balance and approximate hold space; sell/buy pages get Back, main menu gets Close via the dialog exitAction slot - economy.PriceEngine ports BlueBook's logic in-addon: base-prices table + recursive recipe derivation (cycle-safe, smelting fuel share, output division), durability and enchantment weights; reflection bridge and softdepend removed; injectable recipe source, fully tested - 109 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 4 +- TESTING.md | 15 +- docs/PROGRESS.md | 16 ++ .../world/bentobox/tradewinds/Settings.java | 13 +- .../world/bentobox/tradewinds/TradeWinds.java | 3 + .../tradewinds/economy/MarketService.java | 64 ++--- .../tradewinds/economy/PriceEngine.java | 221 ++++++++++++++++++ .../tradewinds/economy/TradeDialog.java | 46 +++- .../tradewinds/travel/HoldService.java | 36 +++ src/main/resources/addon.yml | 2 +- src/main/resources/config.yml | 5 +- .../tradewinds/economy/PriceEngineTest.java | 135 +++++++++++ .../tradewinds/travel/HoldServiceTest.java | 10 + 13 files changed, 500 insertions(+), 70 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/economy/PriceEngine.java create mode 100644 src/test/java/world/bentobox/tradewinds/economy/PriceEngineTest.java diff --git a/CLAUDE.md b/CLAUDE.md index bfc3a27..95a4c64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,8 +67,8 @@ renaming resources. MockBukkit, `docs/API_VERIFICATION.md` (verified 26.2 API facts). - `~/git/Boxed` — structure/jigsaw placement patterns (Stage 2). - `~/git/Border` — border display addon (passable visual border, Stage 3). -- `~/git/bluebook` — economy prices: `BlueBook#getPriceEngine()`, - `PriceEngine.getPrice(ItemStack, worldName)` (Stage 4). +- `~/git/bluebook` — origin of the pricing logic now embedded in + `economy.PriceEngine` (recipe-derived base prices). Follow BentoBox conventions: `Config` + `@ConfigEntry`/`@StoreAt`, `Database` + cache managers, `FlagListener`, `DefaultPlayerCommand` diff --git a/TESTING.md b/TESTING.md index d36bb76..0436146 100644 --- a/TESTING.md +++ b/TESTING.md @@ -204,8 +204,10 @@ the listener/tether; respawn accounting also applies everywhere):** ## Stage 4 — Economy and cargo -Vault + an economy plugin must be running (they are on the test server). BlueBook is -optional — without it the fallback price table applies (console logs which). +Vault + an economy plugin must be running (they are on the test server). Base prices +come from the embedded price engine: the config table (`economy.base-prices`) plus +recipe derivation — e.g. HAY_BLOCK derives from 9x wheat even if unlisted, and a +crafted item made of priced parts is automatically sellable. - [ ] New player: starter kit now also deposits the starting balance ($250). - [ ] **Open the market**: right-click any resident villager, or `/tw trade` anywhere @@ -231,3 +233,12 @@ optional — without it the fallback price table applies (console logs which). - [ ] Expanders cannot be crafted (no shulker shells exist — no End). - [ ] A 4th bundle in your inventory does NOT add hold space (max-bundles 3). - [ ] Without a chest boat and bundles, buying refuses with "no room in your hold". + +### Stage 4b — trade screen UX + embedded pricing (feedback round) +- [ ] Every market screen shows "Balance: $X | Hold space: ~N items", updating after + each transaction. +- [ ] Sell and Buy pages have a "< Back" button returning to the main menu; the main + menu has "Close". +- [ ] Recipe-derived pricing works: an item NOT in the base price table but craftable + from priced parts (e.g. a HAY_BLOCK, CRAFTING_TABLE from planks) gets a sane + sell price; junk with no priceable recipe (e.g. a stray BEDROCK) is untradeable. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 8e710cc..0d09a21 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,22 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 4b — Trade UX + embedded BlueBook pricing (2026-07-30) + +Feedback round on the market screens, plus a direction change from Ben: +- Every dialog page shows balance + approximate hold space + (`HoldService.freeSpace`: empty slots x 64 + stack headroom + bundle + weight); sub-pages get a Back exit button, the main menu a Close (the + dialog API's exitAction slot). +- **BlueBook's pricing logic is now embedded** (`economy.PriceEngine`, + ported from ~/git/bluebook PriceEngine) instead of referenced as a plugin: + config base table (renamed `economy.base-prices`) + recursive recipe + derivation (depth 6, cycle-safe, cooking adds COAL/8 fuel share, + stonecutting passthrough, output-count division), durability scaling and + enchantment weights included for later gear-selling. Reflection bridge and + BlueBook softdepend removed; cache invalidated on reload. Recipe source is + injectable - derivation is fully unit-tested (109 total green). + ## Stage 4 — Economy and cargo (2026-07-29) — CODE COMPLETE, awaiting in-game test The game becomes a game: buy low, sell high. diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 675a77b..bae2c7e 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -297,11 +297,12 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "economy.max-bundles") private int maxBundles = 3; - @ConfigComment("Base prices used when BlueBook is not installed. Material -> price.") - @ConfigEntry(path = "economy.fallback-prices") - private Map fallbackPrices = defaultFallbackPrices(); + @ConfigComment("Base prices (Material -> price). Anything not listed is priced by deriving") + @ConfigComment("from its crafting recipe (BlueBook logic, embedded); underivable = untradeable.") + @ConfigEntry(path = "economy.base-prices") + private Map basePrices = defaultBasePrices(); - private static Map defaultFallbackPrices() { + private static Map defaultBasePrices() { Map map = new HashMap<>(); map.put("WHEAT", 2.0); map.put("CARROT", 1.5); map.put("POTATO", 1.5); map.put("BEETROOT", 1.5); map.put("SUGAR_CANE", 1.0); map.put("SUGAR", 1.5); map.put("PUMPKIN", 3.0); map.put("MELON_SLICE", 0.5); @@ -2305,8 +2306,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setExpanderCap(int expanderCap) { this.expanderCap = expanderCap; } public int getMaxBundles() { return maxBundles; } public void setMaxBundles(int maxBundles) { this.maxBundles = maxBundles; } - public Map getFallbackPrices() { return fallbackPrices; } - public void setFallbackPrices(Map fallbackPrices) { this.fallbackPrices = fallbackPrices; } + public Map getBasePrices() { return basePrices; } + public void setBasePrices(Map basePrices) { this.basePrices = basePrices; } public boolean isIllegalTradeEnabled() { return illegalTradeEnabled; } public void setIllegalTradeEnabled(boolean illegalTradeEnabled) { this.illegalTradeEnabled = illegalTradeEnabled; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 3081bc4..fb8346d 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -274,6 +274,9 @@ public WorldSettings getWorldSettings() { @Override public void onReload() { if (loadSettings()) { + if (marketService != null) { + marketService.getPriceEngine().invalidate(); + } log("Reloaded TradeWinds settings"); } } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 77b2f10..b3606b6 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -1,6 +1,5 @@ package world.bentobox.tradewinds.economy; -import java.lang.reflect.Method; import java.util.Optional; import org.bukkit.Material; @@ -19,9 +18,9 @@ import world.bentobox.tradewinds.galaxy.IslandSpec; /** - * The market: prices and trades. Base prices come from BlueBook when it is - * installed (looked up reflectively - soft dependency), else from the - * fallback table in config. Island prices apply the type/band/stock model + * The market: prices and trades. Base prices come from the embedded + * {@link PriceEngine} (config table plus recipe derivation - BlueBook logic + * carried in-addon). Island prices apply the type/band/stock model * ({@link PriceModel}); all transactions move items through the hold ONLY and * money through Vault. * @@ -33,12 +32,18 @@ public class MarketService { public static final NamespacedKey EXPANDER_KEY = NamespacedKey.fromString("tradewinds:expander"); private final TradeWinds addon; - private Object priceEngine; - private Method getPriceMethod; - private boolean blueBookChecked; + private final PriceEngine priceEngine; public MarketService(TradeWinds addon) { this.addon = addon; + this.priceEngine = new PriceEngine(addon); + } + + /** + * @return the embedded price engine + */ + public PriceEngine getPriceEngine() { + return priceEngine; } /** @@ -51,16 +56,12 @@ public PriceModel model() { } /** - * Base price of a material: BlueBook if present, else the fallback table. - * Empty means not tradeable. + * Base price of a material: configured, or derived from crafting recipes + * by the embedded price engine. Empty means not tradeable. */ public Optional basePrice(Material material) { - Double blueBook = blueBookPrice(material); - if (blueBook != null && blueBook > 0) { - return Optional.of(blueBook); - } - Double fallback = addon.getSettings().getFallbackPrices().get(material.name()); - return fallback != null && fallback > 0 ? Optional.of(fallback) : Optional.empty(); + double price = priceEngine.getPrice(new ItemStack(material)); + return price > 0 ? Optional.of(price) : Optional.empty(); } /** @@ -204,38 +205,7 @@ public ItemStack expanderItem() { return item; } - /** - * Reflective BlueBook lookup: PriceEngine.getPrice(ItemStack, worldName). - * Soft dependency - absent or failing BlueBook just means fallback prices. - */ - private Double blueBookPrice(Material material) { - if (!blueBookChecked) { - blueBookChecked = true; - addon.getPlugin().getAddonsManager().getAddonByName("BlueBook").ifPresent(bb -> { - try { - Object engine = bb.getClass().getMethod("getPriceEngine").invoke(bb); - priceEngine = engine; - getPriceMethod = engine.getClass().getMethod("getPrice", ItemStack.class, String.class); - addon.log("Using BlueBook for base prices"); - } catch (ReflectiveOperationException e) { - addon.logError("BlueBook found but its price API is unavailable: " + e.getMessage()); - } - }); - } - if (priceEngine == null || getPriceMethod == null) { - return null; - } - try { - Object price = getPriceMethod.invoke(priceEngine, new ItemStack(material), - addon.getSettings().getWorldName()); - return price instanceof Number number ? number.doubleValue() : null; - } catch (ReflectiveOperationException e) { - return null; - } - } - static String pretty(Material material) { - String name = material.name().toLowerCase(java.util.Locale.ENGLISH).replace('_', ' '); - return Character.toUpperCase(name.charAt(0)) + name.substring(1); + return PriceEngine.prettify(material.name()); } } diff --git a/src/main/java/world/bentobox/tradewinds/economy/PriceEngine.java b/src/main/java/world/bentobox/tradewinds/economy/PriceEngine.java new file mode 100644 index 0000000..d2be622 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/PriceEngine.java @@ -0,0 +1,221 @@ +package world.bentobox.tradewinds.economy; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.CookingRecipe; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.ShapelessRecipe; +import org.bukkit.inventory.StonecuttingRecipe; +import org.bukkit.inventory.meta.Damageable; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Base price engine, ported from BlueBook (BentoBoxWorld/BlueBook) so + * TradeWinds carries its own pricing logic. A material's base price is either + * configured (economy.base-prices) or derived from its crafting recipe: + * ingredient costs recurse (depth-capped, cycle-detected), smelting adds a + * fuel share, stonecutting passes through, and the result is divided by the + * recipe's output count. Damaged items are worth proportionally less. + *

+ * Island modifiers (type, band, stock drift) are applied on top by + * {@link MarketService}; this class only answers "what is this worth, + * anywhere". + * + * @author tastybento + */ +public class PriceEngine { + + private static final int MAX_RECIPE_DEPTH = 6; + + private final TradeWinds addon; + private final Function> recipeSource; + // Material name -> resolved base price + private final Map cache = new HashMap<>(); + + public PriceEngine(TradeWinds addon) { + this(addon, Bukkit::getRecipesFor); + } + + /** + * Test constructor with an injectable recipe source. + */ + PriceEngine(TradeWinds addon, Function> recipeSource) { + this.addon = addon; + this.recipeSource = recipeSource; + } + + /** + * Clears the price cache. Call after a settings reload. + */ + public void invalidate() { + cache.clear(); + } + + /** + * The base price for an item, or a negative value if it cannot be priced + * (not configured and not craftable from priced ingredients). + */ + public double getPrice(ItemStack item) { + if (item == null || item.getType().isAir()) { + return -1; + } + String key = item.getType().name(); + Double base = cache.get(key); + if (base == null) { + Double configured = addon.getSettings().getBasePrices().get(key); + base = configured != null ? configured : deriveFromRecipes(item, new HashSet<>(), 0); + if (base >= 0) { + cache.put(key, base); + } + } + if (base < 0) { + return base; + } + return applyDurability(item, base); + } + + /** + * A value representing the enchantment quality of an item - multiply by a + * config factor and add as a price fraction when selling gear. + */ + public double getEnchantmentValue(ItemStack item) { + if (item == null || item.getEnchantments().isEmpty()) { + return 0.0; + } + double value = 0.0; + for (Map.Entry entry : item.getEnchantments().entrySet()) { + value += enchantWeight(entry.getKey()) * entry.getValue(); + } + return value; + } + + private double deriveFromRecipes(ItemStack item, Set visited, int depth) { + if (depth > MAX_RECIPE_DEPTH) { + return -1; + } + String key = item.getType().name(); + Double known = cache.containsKey(key) ? cache.get(key) : addon.getSettings().getBasePrices().get(key); + if (known != null) { + return known; + } + if (!visited.add(key)) { + return -1; + } + + List recipes = recipeSource.apply(item); + if (recipes.isEmpty()) { + return -1; + } + Recipe recipe = recipes.get(0); + int output = Math.max(1, recipe.getResult().getAmount()); + double cost = 0; + + if (recipe instanceof ShapedRecipe shaped) { + for (ItemStack ingredient : shaped.getIngredientMap().values()) { + if (ingredient == null || ingredient.getType().isAir()) { + continue; + } + double ingredientCost = deriveFromRecipes(ingredient, new HashSet<>(visited), depth + 1); + if (ingredientCost < 0) { + return -1; + } + cost += ingredientCost * ingredient.getAmount(); + } + } else if (recipe instanceof ShapelessRecipe shapeless) { + for (ItemStack ingredient : shapeless.getIngredientList()) { + if (ingredient == null || ingredient.getType().isAir()) { + continue; + } + double ingredientCost = deriveFromRecipes(ingredient, new HashSet<>(visited), depth + 1); + if (ingredientCost < 0) { + return -1; + } + cost += ingredientCost * ingredient.getAmount(); + } + } else if (recipe instanceof CookingRecipe cooking) { + double inputCost = deriveFromRecipes(cooking.getInput(), new HashSet<>(visited), depth + 1); + if (inputCost < 0) { + return -1; + } + // A coal smelts 8 items: each output carries its share of fuel + double fuelCost = addon.getSettings().getBasePrices().getOrDefault("COAL", 1.0) / 8.0; + cost = inputCost + fuelCost; + } else if (recipe instanceof StonecuttingRecipe stonecutting) { + double inputCost = deriveFromRecipes(stonecutting.getInput(), new HashSet<>(visited), depth + 1); + if (inputCost < 0) { + return -1; + } + cost = inputCost; + } else { + return -1; + } + + return cost / output; + } + + private double applyDurability(ItemStack item, double base) { + int maxDurability = item.getType().getMaxDurability(); + if (maxDurability <= 0) { + return base; + } + if (item.getItemMeta() instanceof Damageable damageable && damageable.hasDamage() + && damageable.getDamage() > 0) { + return base * (1.0 - (double) damageable.getDamage() / maxDurability); + } + return base; + } + + private double enchantWeight(Enchantment enchantment) { + if (enchantment.equals(Enchantment.THORNS) || enchantment.equals(Enchantment.INFINITY) + || enchantment.equals(Enchantment.SILK_TOUCH)) { + return 10.0; + } + if (enchantment.equals(Enchantment.AQUA_AFFINITY) || enchantment.equals(Enchantment.BLAST_PROTECTION) + || enchantment.equals(Enchantment.RESPIRATION) || enchantment.equals(Enchantment.FIRE_ASPECT) + || enchantment.equals(Enchantment.LOOTING) || enchantment.equals(Enchantment.FLAME) + || enchantment.equals(Enchantment.PUNCH) || enchantment.equals(Enchantment.FORTUNE)) { + return 5.0; + } + if (enchantment.equals(Enchantment.BANE_OF_ARTHROPODS) || enchantment.equals(Enchantment.KNOCKBACK) + || enchantment.equals(Enchantment.SMITE)) { + return 2.5; + } + if (enchantment.equals(Enchantment.FEATHER_FALLING) || enchantment.equals(Enchantment.FIRE_PROTECTION) + || enchantment.equals(Enchantment.PROJECTILE_PROTECTION)) { + return 2.0; + } + return 1.0; + } + + /** + * Converts UPPER_SNAKE_CASE to Title Case (e.g. OAK_LOG -> Oak Log). + */ + public static String prettify(String name) { + if (name == null || name.isEmpty()) { + return name; + } + String[] parts = name.toLowerCase(java.util.Locale.ENGLISH).split("_"); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + sb.append(' '); + } + if (!parts[i].isEmpty()) { + sb.append(Character.toUpperCase(parts[i].charAt(0))); + sb.append(parts[i].substring(1)); + } + } + return sb.toString(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 7ef58b0..2c8ab23 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -43,7 +43,6 @@ public TradeDialog(TradeWinds addon) { * Open the market's main menu. */ public void openMain(Player player, IslandSpec spec) { - double balance = addon.getPlugin().getVault().map(v -> v.getBalance(User.getInstance(player))).orElse(0.0); List buttons = new ArrayList<>(); buttons.add(button("Sell cargo", NamedTextColor.YELLOW, "Sell your hold's goods here", () -> openSell(player, spec))); @@ -62,8 +61,9 @@ public void openMain(Player player, IslandSpec spec) { openMain(player, spec); })); } - show(player, spec.name() + " Market", "Balance: $" + String.format("%.2f", balance) + " | " - + spec.type().name() + ", " + spec.band().getDisplayName(), buttons); + show(player, spec.name() + " Market", + List.of(spec.type().name() + ", " + spec.band().getDisplayName(), statusLine(player)), buttons, + closeButton()); } /** @@ -83,9 +83,12 @@ public void openSell(Player player, IslandSpec spec) { } if (buttons.isEmpty()) { User.getInstance(player).sendMessage("tradewinds.trade.nothing-to-sell"); + openMain(player, spec); return; } - show(player, spec.name() + " - Selling", "The island pays for goods in your hold", buttons); + show(player, spec.name() + " - Selling", + List.of("The island pays for goods in your hold", statusLine(player)), buttons, + backButton(player, spec)); } /** @@ -106,9 +109,11 @@ public void openBuy(Player player, IslandSpec spec) { } if (buttons.isEmpty()) { User.getInstance(player).sendMessage("tradewinds.trade.nothing-for-sale"); + openMain(player, spec); return; } - show(player, spec.name() + " - Buying", "Goods sold into your hold", buttons); + show(player, spec.name() + " - Buying", + List.of("Goods sold into your hold", statusLine(player)), buttons, backButton(player, spec)); } /** @@ -128,18 +133,39 @@ List sellOffers(Player player, IslandSpec spec) { return offers; } + /** + * Balance and hold space - shown on every market screen so traders always + * know what they can afford and what they can carry. + */ + private String statusLine(Player player) { + double balance = addon.getPlugin().getVault().map(v -> v.getBalance(User.getInstance(player))).orElse(0.0); + return String.format("Balance: $%.2f | Hold space: ~%d items", balance, + addon.getHoldService().freeSpace(player)); + } + + private ActionButton backButton(Player player, IslandSpec spec) { + return button("< Back", NamedTextColor.GRAY, "Back to the market menu", () -> openMain(player, spec)); + } + + private ActionButton closeButton() { + // No action: clicking simply closes the dialog + return ActionButton.builder(Component.text("Close", NamedTextColor.GRAY)).width(300).build(); + } + private ActionButton button(String label, NamedTextColor color, String tooltip, Runnable action) { return ActionButton.create(Component.text(label, color), Component.text(tooltip), 300, DialogAction.customClick((response, audience) -> action.run(), ClickCallback.Options.builder().build())); } - private void show(Player player, String title, String body, List buttons) { + private void show(Player player, String title, List bodyLines, List buttons, + ActionButton exitButton) { + List body = bodyLines.stream() + .map(line -> (DialogBody) DialogBody.plainMessage(Component.text(line, NamedTextColor.GRAY))) + .toList(); Dialog dialog = Dialog.create(factory -> factory.empty() - .base(DialogBase.builder(Component.text(title)) - .body(List.of(DialogBody.plainMessage(Component.text(body, NamedTextColor.GRAY)))) - .build()) - .type(DialogType.multiAction(buttons).columns(1).build())); + .base(DialogBase.builder(Component.text(title)).body(body).build()) + .type(DialogType.multiAction(buttons).exitAction(exitButton).columns(1).build())); player.showDialog(dialog); } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index 78c91a3..551269d 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -52,6 +52,42 @@ public int count(Player player, Material material) { return contents(player).getOrDefault(material, 0); } + /** + * Approximate free capacity of the hold in items, assuming 64-stacks: + * empty boat/expander slots count 64, partial stacks their headroom, + * bundles their remaining weight. + */ + public int freeSpace(Player player) { + int free = 0; + if (player.getVehicle() instanceof ChestBoat boat) { + free += freeIn(boat.getInventory()); + for (ItemStack stack : boat.getInventory().getContents()) { + if (isExpander(stack) && stack.getItemMeta() instanceof BlockStateMeta meta + && meta.getBlockState() instanceof ShulkerBox box) { + free += freeIn(box.getInventory()); + } + } + } + for (ItemStack bundleItem : bundles(player)) { + if (bundleItem.getItemMeta() instanceof BundleMeta bundle) { + free += Math.max(0, 64 - bundle.getItems().stream().mapToInt(ItemStack::getAmount).sum()); + } + } + return free; + } + + private int freeIn(Inventory inventory) { + int free = 0; + for (ItemStack stack : inventory.getStorageContents()) { + if (stack == null || stack.getType().isAir()) { + free += 64; + } else if (stack.getMaxStackSize() >= 64 && !isExpander(stack)) { + free += stack.getMaxStackSize() - stack.getAmount(); + } + } + return free; + } + /** * Remove up to {@code amount} of a material from the hold (chest boat * first, then expanders, then bundles). diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index e1c452f..2b6d557 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -6,7 +6,7 @@ metrics: true icon: "OAK_CHEST_BOAT" repository: "BentoBoxWorld/TradeWinds" authors: tastybento -softdepend: BlueBook, Border, Level, Challenges, Visit, Warps +softdepend: Border, Level, Challenges, Visit, Warps permissions: tradewinds.island: diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 03efc4d..be994af 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -136,8 +136,9 @@ economy: expander-cap: 4 # Maximum trading bundles that count as hold space. max-bundles: 3 - # Base prices used when BlueBook is not installed (Material -> price). - fallback-prices: + # Base prices (Material -> price). Anything not listed is priced by deriving from + # its crafting recipe (BlueBook logic, embedded); underivable = untradeable. + base-prices: WHEAT: 2.0 CARROT: 1.5 POTATO: 1.5 diff --git a/src/test/java/world/bentobox/tradewinds/economy/PriceEngineTest.java b/src/test/java/world/bentobox/tradewinds/economy/PriceEngineTest.java new file mode 100644 index 0000000..aa1c39e --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/PriceEngineTest.java @@ -0,0 +1,135 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.bukkit.Material; +import org.bukkit.inventory.FurnaceRecipe; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.ShapelessRecipe; +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.TradeWinds; + +/** + * Tests the embedded (ex-BlueBook) price engine: config prices, recipe + * derivation, smelting fuel share, unpriceable goods, cycle safety. + * + * @author tastybento + */ +class PriceEngineTest extends CommonTestSetup { + + private TradeWinds addon; + private Map> recipes; + private PriceEngine engine; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + when(addon.getSettings()).thenReturn(new Settings()); + recipes = new java.util.HashMap<>(); + Function> source = item -> recipes.entrySet().stream() + .filter(e -> e.getKey().getType() == item.getType()).map(Map.Entry::getValue).findFirst() + .orElse(List.of()); + engine = new PriceEngine(addon, source); + } + + @Test + void testConfiguredPrice() { + // WHEAT is in the default base price table at 2.0 + assertEquals(2.0, engine.getPrice(new ItemStack(Material.WHEAT))); + } + + @Test + void testUnpriceableWithoutRecipe() { + assertTrue(engine.getPrice(new ItemStack(Material.BEDROCK)) < 0); + assertTrue(engine.getPrice(null) < 0); + } + + @Test + void testShapelessDerivation() { + // Hypothetical: 1 GOLDEN_CARROT from 8 GOLD_NUGGET + 1 CARROT... use a + // recipe of priced parts: BONE_MEAL-like: 1 HAY_BLOCK -> 9 WHEAT is + // shaped in vanilla; emulate shapeless: HAY_BLOCK from 9 wheat + ShapelessRecipe recipe = mock(ShapelessRecipe.class); + when(recipe.getResult()).thenReturn(new ItemStack(Material.HAY_BLOCK, 1)); + when(recipe.getIngredientList()).thenReturn(List.of(new ItemStack(Material.WHEAT, 9))); + recipes.put(new ItemStack(Material.HAY_BLOCK), List.of(recipe)); + // HAY_BLOCK is configured at 18.0 in defaults - remove to force derivation + addonSettingsWithout("HAY_BLOCK"); + assertEquals(9 * 2.0, engine.getPrice(new ItemStack(Material.HAY_BLOCK))); + } + + @Test + void testOutputCountDividesCost() { + // 1 WHEAT (2.0) -> 4 of something: each is 0.5 + ShapelessRecipe recipe = mock(ShapelessRecipe.class); + when(recipe.getResult()).thenReturn(new ItemStack(Material.PAPER, 4)); + when(recipe.getIngredientList()).thenReturn(List.of(new ItemStack(Material.WHEAT, 1))); + recipes.put(new ItemStack(Material.PAPER), List.of(recipe)); + assertEquals(0.5, engine.getPrice(new ItemStack(Material.PAPER))); + } + + @Test + void testSmeltingAddsFuelShare() { + // COOKED_MUTTON from MUTTON (2.0 configured) + COAL(4.0)/8 = 2.5 + FurnaceRecipe recipe = mock(FurnaceRecipe.class); + when(recipe.getResult()).thenReturn(new ItemStack(Material.COOKED_MUTTON, 1)); + when(recipe.getInput()).thenReturn(new ItemStack(Material.MUTTON)); + recipes.put(new ItemStack(Material.COOKED_MUTTON), List.of(recipe)); + assertEquals(2.0 + 4.0 / 8.0, engine.getPrice(new ItemStack(Material.COOKED_MUTTON))); + } + + @Test + void testRecursiveDerivation() { + // BONE_BLOCK <- 9 BONE_MEAL <- (3 from) BONE... chain: A from B from configured WHEAT + ShapelessRecipe inner = mock(ShapelessRecipe.class); + when(inner.getResult()).thenReturn(new ItemStack(Material.PAPER, 1)); + when(inner.getIngredientList()).thenReturn(List.of(new ItemStack(Material.WHEAT, 2))); + recipes.put(new ItemStack(Material.PAPER), List.of(inner)); + ShapelessRecipe outer = mock(ShapelessRecipe.class); + when(outer.getResult()).thenReturn(new ItemStack(Material.BOOK, 1)); + when(outer.getIngredientList()).thenReturn(List.of(new ItemStack(Material.PAPER, 3))); + recipes.put(new ItemStack(Material.BOOK), List.of(outer)); + // BOOK = 3 x PAPER = 3 x (2 x 2.0) = 12.0 + assertEquals(12.0, engine.getPrice(new ItemStack(Material.BOOK))); + } + + @Test + void testCircularRecipesAreSafe() { + // A crafts from B, B crafts from A - must return unpriceable, not hang + ShapelessRecipe a = mock(ShapelessRecipe.class); + when(a.getResult()).thenReturn(new ItemStack(Material.PAPER, 1)); + when(a.getIngredientList()).thenReturn(List.of(new ItemStack(Material.BOOK, 1))); + recipes.put(new ItemStack(Material.PAPER), List.of(a)); + ShapelessRecipe b = mock(ShapelessRecipe.class); + when(b.getResult()).thenReturn(new ItemStack(Material.BOOK, 1)); + when(b.getIngredientList()).thenReturn(List.of(new ItemStack(Material.PAPER, 1))); + recipes.put(new ItemStack(Material.BOOK), List.of(b)); + assertTrue(engine.getPrice(new ItemStack(Material.BOOK)) < 0); + } + + @Test + void testPrettify() { + assertEquals("Oak Log", PriceEngine.prettify("OAK_LOG")); + assertEquals("Wheat", PriceEngine.prettify("WHEAT")); + } + + private void addonSettingsWithout(String material) { + Settings settings = new Settings(); + settings.getBasePrices().remove(material); + when(addon.getSettings()).thenReturn(settings); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java index efbe8aa..049fb4c 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java @@ -78,6 +78,16 @@ void testRemove() { assertEquals(15, service.remove(mockPlayer, Material.WHEAT, 99)); } + @Test + void testFreeSpace() { + // Boat: wheat 40/64 (+24), coal 10/64 (+54), one empty slot (+64) + when(boatInv.getStorageContents()).thenReturn(new ItemStack[] { wheat, coal, null }); + assertEquals(24 + 54 + 64, service.freeSpace(mockPlayer)); + // No boat, no bundles: no hold at all + when(mockPlayer.getVehicle()).thenReturn(null); + assertEquals(0, service.freeSpace(mockPlayer)); + } + @Test void testAddUsesBoatInventory() { when(boatInv.addItem(org.mockito.ArgumentMatchers.any(ItemStack.class))).thenReturn(new HashMap<>()); From fca87ec80bf521d841ff40832e2376b34815e02e Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 08:26:51 -0700 Subject: [PATCH 014/112] Cap warp dialog at 8 destinations so the list fits without scrolling Scrolling is easy to miss (kids, accessibility). Default and shipped config 20 -> 8; live test-server config updated. 109 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- docs/PROGRESS.md | 8 ++++++++ src/main/java/world/bentobox/tradewinds/Settings.java | 3 ++- src/main/resources/config.yml | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 0d09a21..020e035 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,14 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Accessibility tune: warp dialog capped at 8 (2026-07-30) + +The dialog scrolls beyond ~8 buttons, but the scroll affordance is easy to +miss (kids, accessibility). `travel.warp.max-destinations` default 20 -> 8 so +the list always fits on screen; live server config updated too (stored +values beat new defaults). Charting more islands is still fine - the eight +NEAREST are listed. + ## Stage 4b — Trade UX + embedded BlueBook pricing (2026-07-30) Feedback round on the market screens, plus a direction change from Ben: diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index bae2c7e..52eb84e 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -199,8 +199,9 @@ private static Map defaultBandHurtVillagers() { private int warpPromptCooldownSeconds = 30; @ConfigComment("Maximum destinations listed in the warp dialog (nearest first).") + @ConfigComment("8 fits the dialog without scrolling - scrolling is easy to miss.") @ConfigEntry(path = "travel.warp.max-destinations") - private int maxWarpDestinations = 20; + private int maxWarpDestinations = 8; @ConfigComment("Seconds of nausea after a warp. Warping hurts - it gates the under-equipped.") @ConfigEntry(path = "travel.warp.nausea-seconds") diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index be994af..02cea52 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -54,7 +54,8 @@ travel: # Seconds between automatic warp-dialog offers at the same island's border. prompt-cooldown-seconds: 30 # Maximum destinations listed in the warp dialog (nearest first). - max-destinations: 20 + # 8 fits the dialog without scrolling - scrolling is easy to miss. + max-destinations: 8 # Warping hurts - it gates the under-equipped. nausea-seconds: 8 blindness-seconds: 3 From 0d3eaa09612ecb034e728d22553ebbeaa2e9845f Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 18:17:12 -0700 Subject: [PATCH 015/112] Spawn islet at the origin + safe bed-less respawns Vanilla respawn entombed players in the seabed (0,42,0 death loop). The galaxy engine now raises a small plains islet at 0,0 (generator-made, galaxy.spawn-islet-radius, galaxy islands unaffected), world spawn pins to its surface, and SpawnRespawnListener sends bed-less deaths in TradeWinds worlds there. Existing worlds: delete the four origin region files. 114 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 17 +++++ docs/PROGRESS.md | 21 ++++++ .../world/bentobox/tradewinds/Settings.java | 7 ++ .../world/bentobox/tradewinds/TradeWinds.java | 9 ++- .../tradewinds/galaxy/GalaxyConfig.java | 18 +++++- .../tradewinds/galaxy/GalaxyEngine.java | 14 ++++ .../listeners/SpawnRespawnListener.java | 45 +++++++++++++ src/main/resources/config.yml | 3 + .../tradewinds/galaxy/GalaxyEngineTest.java | 15 +++++ .../generator/ChunkGeneratorWorldTest.java | 20 +++++- .../TradeWindsBiomeProviderTest.java | 6 +- .../listeners/SpawnRespawnListenerTest.java | 64 +++++++++++++++++++ 12 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java create mode 100644 src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java diff --git a/TESTING.md b/TESTING.md index 0436146..2fe904b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -242,3 +242,20 @@ crafted item made of priced parts is automatically sellable. - [ ] Recipe-derived pricing works: an item NOT in the base price table but craftable from priced parts (e.g. a HAY_BLOCK, CRAFTING_TABLE from planks) gets a sane sell price; junk with no priceable recipe (e.g. a stray BEDROCK) is untradeable. + +### Stage 4c — Spawn islet and safe respawn (playtest fix) + +**Existing worlds**: the islet only appears in freshly generated chunks. Stop the +server and delete the four region files around the origin +(`tradewinds_world/region/r.0.0.mca`, `r.-1.0.mca`, `r.0.-1.mca`, `r.-1.-1.mca`) — +everything else (islands, database) is untouched; the area regenerates with the islet. + +- [ ] A grassy plains islet (~96 blocks across) exists at 0,0; world spawn is on top + of it. +- [ ] `/tw` teleports onto the islet (dry land). First-time players get the bundle and + a boat ITEM (spawn is land now, not water). +- [ ] Die without a bed: you respawn ON the islet — not at 0,42,0 in the seabed, and + not drowning. Repeat deaths respawn there every time. +- [ ] Die with a bed set somewhere: the bed is honored. +- [ ] Dying in the interstice (admin-teleport there) without a bed also returns you to + the islet. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 020e035..b3befe6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,27 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest fix: spawn islet + safe respawn (2026-07-30) + +Ben died and vanilla respawn hunted for "solid ground" near world spawn - +which in an ocean world is the seabed at y42, entombing him in a death loop. +Fix in three parts: +- **Spawn islet**: GalaxyConfig.spawnIsletRadius (config + galaxy.spawn-islet-radius, 48) - the engine's landLiftAt raises a small + plains islet at the origin (generator-made, principle 6; deterministic and + independent of galaxy islands, which stay bit-identical). needsReset: only + fresh chunks get it - delete the four origin region files on existing + worlds. +- **World spawn pinned** to the islet surface (getHighestBlockYAt at enable). +- **SpawnRespawnListener**: deaths in either TradeWinds world without a + bed/anchor respawn on the islet; bed and anchor spawns honored; other + gamemodes untouched. StarterKit's land branch now applies at first spawn + (boat item rather than water launch). +No BentoBox island object at spawn (a range-1000 island at the origin can +overlap a jittered starter island's range and break grid registration - see +the round-1 fix notes); protection of the islet itself is deferred. +114 tests green. + ## Accessibility tune: warp dialog capped at 8 (2026-07-30) The dialog scrolls beyond ~8 buttons, but the scroll affordance is easy to diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 52eb84e..75962ec 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -95,6 +95,11 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.band-radius", needsReset = true) private int bandRadius = 5000; + @ConfigComment("Radius of the safe spawn islet generated at the world origin - players spawn") + @ConfigComment("and respawn there. 0 disables it (not recommended: seabed respawns kill).") + @ConfigEntry(path = "galaxy.spawn-islet-radius", needsReset = true) + private int spawnIsletRadius = 48; + @ConfigComment("Relative spawn weight per island type. Higher = more common; 0 disables a type.") @ConfigComment("Types: AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN.") @ConfigEntry(path = "galaxy.type-weights", needsReset = true) @@ -2243,6 +2248,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setLandLift(int landLift) { this.landLift = landLift; } public int getBandRadius() { return bandRadius; } public void setBandRadius(int bandRadius) { this.bandRadius = bandRadius; } + public int getSpawnIsletRadius() { return spawnIsletRadius; } + public void setSpawnIsletRadius(int spawnIsletRadius) { this.spawnIsletRadius = spawnIsletRadius; } public Map getTypeWeights() { return typeWeights; } public void setTypeWeights(Map typeWeights) { this.typeWeights = typeWeights; } public double getFuelPerBlock() { return fuelPerBlock; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index fb8346d..803d781 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -193,9 +193,11 @@ public void onEnable() { // Navigation boss bar: island, standing, distance to dock navigationBarTask = new NavigationBarTask(this); navigationBarTask.start(); - // Deterministic ocean spawn on the sea surface at the galaxy origin + // Spawn (and bed-less respawn) is on the spawn islet's surface + registerListener(new world.bentobox.tradewinds.listeners.SpawnRespawnListener(this)); if (islandWorld != null) { - islandWorld.setSpawnLocation(0, getSettings().getSeaHeight() + 1, 0); + int top = islandWorld.getHighestBlockYAt(0, 0); + islandWorld.setSpawnLocation(0, Math.max(top + 1, getSettings().getSeaHeight() + 1), 0); } } @@ -364,7 +366,8 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { long seed = s.getGalaxySeed() != 0 ? s.getGalaxySeed() : worldSeed; galaxyEngine = new GalaxyEngine(new GalaxyConfig(seed, s.getGalaxyMinSeparation(), s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), - s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight(), typeWeights())); + s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight(), typeWeights(), + s.getSpawnIsletRadius())); log("TradeWinds galaxy seed: " + seed); } return galaxyEngine; diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java index 28de6c6..c43ebf3 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -21,11 +21,16 @@ * @param seaLevel the world's sea surface Y - dock and plaza heights hang off it * @param typeWeights relative spawn weight per island type; a zero or missing * total falls back to the built-in defaults + * @param spawnIsletRadius radius of the safe spawn islet at the origin (0 = none) * * @author tastybento */ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, - int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights) { + int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, + int spawnIsletRadius) { + + /** Default spawn islet radius in blocks. */ + public static final int DEFAULT_SPAWN_ISLET_RADIUS = 48; public GalaxyConfig { if (typeWeights == null || typeWeights.values().stream().mapToInt(w -> Math.max(0, w)).sum() <= 0) { @@ -33,13 +38,22 @@ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int } } + /** + * Convenience constructor using the default spawn islet radius. + */ + public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, + int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights) { + this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, + typeWeights, DEFAULT_SPAWN_ISLET_RADIUS); + } + /** * Convenience constructor using the built-in type weights. */ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, - defaultTypeWeights()); + defaultTypeWeights(), DEFAULT_SPAWN_ISLET_RADIUS); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index ddcf2ab..682a67d 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -224,6 +224,15 @@ public int landLiftAt(int blockX, int blockZ) { best = Math.max(best, mask); } } + // The spawn islet: a small safe island at the origin so players spawn + // and respawn on dry land, never in the seabed + int isletRadius = config.spawnIsletRadius(); + if (isletRadius > 0) { + double d = Math.hypot(blockX, blockZ); + if (d < isletRadius) { + best = Math.max(best, 0.5 * (1 + Math.cos(Math.PI * d / isletRadius))); + } + } return (int) Math.round(config.landLift() * best); } @@ -303,6 +312,11 @@ public Optional columnPlanAt(int blockX, int blockZ) { * @return biome key, or empty for open ocean */ public Optional biomeKeyAt(int blockX, int blockZ) { + // Spawn islet is gentle plains + if (config.spawnIsletRadius() > 0 + && Math.hypot(blockX, blockZ) < config.spawnIsletRadius()) { + return Optional.of("minecraft:plains"); + } int radius = config.terrainRadius(); long r2 = (long) radius * radius; long ring2 = 4L * radius * radius; diff --git a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java new file mode 100644 index 0000000..1986868 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java @@ -0,0 +1,45 @@ +package world.bentobox.tradewinds.listeners; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerRespawnEvent; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Respawn safety: a player who dies in a TradeWinds world without a bed or + * respawn anchor respawns on the spawn islet - never in the seabed. (Vanilla + * hunts for solid ground near world spawn; in an ocean world that used to + * mean being entombed at the bottom and dying on repeat.) + * + * @author tastybento + */ +public class SpawnRespawnListener implements Listener { + + private final TradeWinds addon; + + public SpawnRespawnListener(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.HIGH) + public void onRespawn(PlayerRespawnEvent event) { + World world = event.getPlayer().getWorld(); + if (!world.equals(addon.getOverWorld()) && !world.equals(addon.getNetherWorld())) { + return; + } + if (event.isBedSpawn() || event.isAnchorSpawn()) { + return; // they made a home somewhere - honor it + } + World overworld = addon.getOverWorld(); + if (overworld == null) { + return; + } + int top = overworld.getHighestBlockYAt(0, 0); + event.setRespawnLocation(new Location(overworld, 0.5, + Math.max(top + 1, addon.getSettings().getSeaHeight() + 1), 0.5)); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 02cea52..ef48253 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -29,6 +29,9 @@ galaxy: land-lift: 45 # Distance from spawn per security-band step (Safe -> Policed -> Frontier -> Lawless -> Anarchic). band-radius: 5000 + # Radius of the safe spawn islet generated at the world origin - players spawn + # and respawn there. 0 disables it (not recommended: seabed respawns kill). + spawn-islet-radius: 48 # Relative spawn weight per island type. Higher = more common; 0 disables a type. type-weights: AGRICULTURAL: 10 diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index 2d51fb3..8b1f18f 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -213,6 +213,21 @@ void testTypeWeightOverrides() { GalaxyConfig.defaultTypeWeights())), 5)); } + @Test + void testSpawnIslet() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + // Full lift at the origin even with an empty galaxy, plains biome + assertEquals(45, engine.landLiftAt(0, 0)); + assertEquals(java.util.Optional.of("minecraft:plains"), engine.biomeKeyAt(0, 0)); + // Gone past the islet radius (default 48) + assertEquals(0, engine.landLiftAt(60, 0)); + assertTrue(engine.biomeKeyAt(60, 0).isEmpty()); + // Radius 0 disables it + GalaxyEngine off = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 5, 5000, 70, + GalaxyConfig.defaultTypeWeights(), 0)); + assertEquals(0, off.landLiftAt(0, 0)); + } + @Test void testNamesAreDistinctEnough() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index c773805..95aab65 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -61,10 +61,10 @@ public void setUp() throws Exception { addon = mock(TradeWinds.class); settings = new Settings(); when(addon.getSettings()).thenReturn(settings); - // Default: an empty galaxy (density 0, no starter islands) - pure ocean + // Default: an empty galaxy (density 0, no starter islands, no spawn islet) - pure ocean when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) - .thenReturn(new GalaxyEngine( - new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70))); + .thenReturn(new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70, + GalaxyConfig.defaultTypeWeights(), 0))); } private WorldInfo worldInfo(Environment env, long seed) { @@ -242,6 +242,20 @@ void testPlazaAndDockTerraform() { assertEquals(Material.AIR, dock.get(dx & 15, deckY + 1, dz & 15)); } + @Test + void testSpawnIsletRisesAtOrigin() { + // Even with an empty galaxy, the spawn islet makes dry land at 0,0 + when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) + .thenReturn(new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70))); + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 0, 0); + boolean landAboveSea = false; + for (int y = settings.getSeaHeight() + 1; y < settings.getSeaHeight() + 50 && !landAboveSea; y++) { + landAboveSea = r.get(0, y, 0) == Material.GRASS_BLOCK; + } + assertTrue(landAboveSea, "Spawn islet should rise above the sea at the origin"); + } + @Test void testShouldGenerateFlags() { ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); diff --git a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java index dcdb34a..36f4a48 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java @@ -43,8 +43,10 @@ public void setUp() throws Exception { addon = mock(TradeWinds.class); settings = new Settings(); when(addon.getSettings()).thenReturn(settings); - emptyGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70)); - denseGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + emptyGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70, + GalaxyConfig.defaultTypeWeights(), 0)); + denseGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70, + GalaxyConfig.defaultTypeWeights(), 0)); when(addon.getGalaxyEngine(anyLong())).thenReturn(emptyGalaxy); provider = new TradeWindsBiomeProvider(addon); } diff --git a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java new file mode 100644 index 0000000..b9e4b33 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java @@ -0,0 +1,64 @@ +package world.bentobox.tradewinds.listeners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.player.PlayerRespawnEvent; +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.TradeWinds; + +/** + * Tests bed-less respawns land on the spawn islet, never in the seabed. + * + * @author tastybento + */ +class SpawnRespawnListenerTest extends CommonTestSetup { + + private TradeWinds addon; + private SpawnRespawnListener listener; + private Location deathBed; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + when(addon.getSettings()).thenReturn(new Settings()); + when(addon.getOverWorld()).thenReturn(world); + when(world.getHighestBlockYAt(0, 0)).thenReturn(95); + when(mockPlayer.getWorld()).thenReturn(world); + listener = new SpawnRespawnListener(addon); + deathBed = new Location(world, 500, 42, 500); + } + + @Test + void testBedlessRespawnGoesToIslet() { + PlayerRespawnEvent event = new PlayerRespawnEvent(mockPlayer, deathBed, false, false); + listener.onRespawn(event); + assertEquals(96, event.getRespawnLocation().getBlockY()); + assertEquals(0, event.getRespawnLocation().getBlockX()); + assertEquals(0, event.getRespawnLocation().getBlockZ()); + } + + @Test + void testBedRespawnIsHonored() { + PlayerRespawnEvent event = new PlayerRespawnEvent(mockPlayer, deathBed, true, false); + listener.onRespawn(event); + assertEquals(deathBed, event.getRespawnLocation()); + } + + @Test + void testOtherWorldsUntouched() { + when(mockPlayer.getWorld()).thenReturn(mock(World.class)); + PlayerRespawnEvent event = new PlayerRespawnEvent(mockPlayer, deathBed, false, false); + listener.onRespawn(event); + assertEquals(deathBed, event.getRespawnLocation()); + } +} From 942f184bf06b38cd74ba758f209b5e9836ccb490 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 21:08:00 -0700 Subject: [PATCH 016/112] Trade quantity rows (x1/x16/All) and hide Sell when hold is empty Dialog buttons cannot distinguish click type, so each cargo row is a three-button grid: x1 | x16 | All (sell) and x1 | x16 | x64 (buy), via multiAction columns(3). MarketService.sell takes an amount cap. Sell button hidden when nothing sellable; sell rows capped at 8 with a truncation note. 114 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 10 +++ docs/PROGRESS.md | 9 ++ .../tradewinds/economy/MarketService.java | 9 +- .../tradewinds/economy/TradeDialog.java | 85 ++++++++++++------- 4 files changed, 78 insertions(+), 35 deletions(-) diff --git a/TESTING.md b/TESTING.md index 2fe904b..ac25a57 100644 --- a/TESTING.md +++ b/TESTING.md @@ -259,3 +259,13 @@ everything else (islands, database) is untouched; the area regenerates with the - [ ] Die with a bed set somewhere: the bed is honored. - [ ] Dying in the interstice (admin-teleport there) without a bed also returns you to the islet. + +### Stage 4d — trade quantities (feedback round) +- [ ] Main menu: with an empty hold (or nothing this island pays for), there is NO + "Sell cargo" button; it appears once sellable cargo is aboard. +- [ ] Sell page: each cargo type is one ROW of three buttons — "x1", "x16", + "All - $total" — selling exactly that many (x16 with only 9 aboard sells 9). +- [ ] Buy page: each catalog good is a row of "x1 / x16 / x64" at quoted totals, + still limited by balance and hold space. +- [ ] More than 8 cargo types: the sell page shows the first 8 with a note; selling + some reveals the rest. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b3befe6..c8b1d29 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,15 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Trade quantities + empty-hold UX (2026-07-30) + +Ben wanted partial sells ("left-click all, right-click one"). Dialog buttons +carry no click-type, so instead each cargo row is THREE grid buttons +(columns(3)): x1 | x16 | All-with-total; buy rows are x1 | x16 | x64. +MarketService.sell gained an amount cap (MAX_VALUE = all). The Sell button +hides when the hold has nothing the island pays for; sell rows cap at 8 +(dialog fits, note shown when truncated). 114 tests green. + ## Playtest fix: spawn islet + safe respawn (2026-07-30) Ben died and vanilla respawn hunted for "solid ground" near world spawn - diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index b3606b6..c13d44e 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -86,17 +86,18 @@ private double factor(IslandSpec spec, Material material) { } /** - * Sell everything of one material from the player's hold to the island. + * Sell up to {@code amount} of one material from the player's hold to the + * island (Integer.MAX_VALUE = everything). * * @return amount sold */ - public int sell(Player player, IslandSpec spec, Material material) { + public int sell(Player player, IslandSpec spec, Material material, int amount) { Optional unitPrice = playerSellsAt(spec, material); Optional vault = addon.getPlugin().getVault(); - if (unitPrice.isEmpty() || vault.isEmpty()) { + if (unitPrice.isEmpty() || vault.isEmpty() || amount <= 0) { return 0; } - int count = addon.getHoldService().count(player, material); + int count = Math.min(addon.getHoldService().count(player, material), amount); if (count <= 0) { return 0; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 2c8ab23..633439d 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -31,7 +31,11 @@ */ public class TradeDialog { - private static final int BUY_BATCH = 16; + /** Sell/buy quantity steps shown side by side per cargo row. */ + private static final int MID_BATCH = 16; + private static final int BIG_BATCH = 64; + /** Rows that fit a dialog without scrolling. */ + private static final int MAX_ROWS = 8; private final TradeWinds addon; @@ -44,8 +48,11 @@ public TradeDialog(TradeWinds addon) { */ public void openMain(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); - buttons.add(button("Sell cargo", NamedTextColor.YELLOW, "Sell your hold's goods here", - () -> openSell(player, spec))); + // No sell button when the hold has nothing this island pays for + if (!sellOffers(player, spec).isEmpty()) { + buttons.add(button("Sell cargo", NamedTextColor.YELLOW, "Sell your hold's goods here", + () -> openSell(player, spec))); + } if (!TypeEconomy.catalog(spec.type()).isEmpty()) { buttons.add(button("Buy goods", NamedTextColor.AQUA, "Buy this island's produce into your hold", () -> openBuy(player, spec))); @@ -63,32 +70,41 @@ public void openMain(Player player, IslandSpec spec) { } show(player, spec.name() + " Market", List.of(spec.type().name() + ", " + spec.band().getDisplayName(), statusLine(player)), buttons, - closeButton()); + closeButton(), 1); } /** * The sell page: one button per hold material this island will pay for. */ public void openSell(Player player, IslandSpec spec) { - List buttons = new ArrayList<>(); - for (SellOffer offer : sellOffers(player, spec)) { - buttons.add(button( - String.format("Sell %d x %s - $%.2f", offer.amount(), MarketService.pretty(offer.material()), - offer.total()), - NamedTextColor.YELLOW, String.format("$%.2f each", offer.unitPrice()), - () -> { - addon.getMarketService().sell(player, spec, offer.material()); - openSell(player, spec); - })); - } - if (buttons.isEmpty()) { + List offers = sellOffers(player, spec); + if (offers.isEmpty()) { User.getInstance(player).sendMessage("tradewinds.trade.nothing-to-sell"); openMain(player, spec); return; } - show(player, spec.name() + " - Selling", - List.of("The island pays for goods in your hold", statusLine(player)), buttons, - backButton(player, spec)); + List buttons = new ArrayList<>(); + for (SellOffer offer : offers.subList(0, Math.min(MAX_ROWS, offers.size()))) { + String name = MarketService.pretty(offer.material()); + String each = String.format("$%.2f each", offer.unitPrice()); + buttons.add(button(name + " x1", NamedTextColor.YELLOW, each, + () -> sellThenReopen(player, spec, offer.material(), 1))); + buttons.add(button(name + " x" + MID_BATCH, NamedTextColor.YELLOW, + String.format("%s - up to $%.2f", each, offer.unitPrice() * MID_BATCH), + () -> sellThenReopen(player, spec, offer.material(), MID_BATCH))); + buttons.add(button(String.format("All %d - $%.2f", offer.amount(), offer.total()), NamedTextColor.GOLD, + each, () -> sellThenReopen(player, spec, offer.material(), Integer.MAX_VALUE))); + } + List body = new ArrayList<>(List.of("The island pays for goods in your hold", statusLine(player))); + if (offers.size() > MAX_ROWS) { + body.add("(showing the first " + MAX_ROWS + " cargo types - sell some to see the rest)"); + } + show(player, spec.name() + " - Selling", body, buttons, backButton(player, spec), 3); + } + + private void sellThenReopen(Player player, IslandSpec spec, Material material, int amount) { + addon.getMarketService().sell(player, spec, material, amount); + openSell(player, spec); } /** @@ -98,14 +114,16 @@ public void openBuy(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); for (Material material : TypeEconomy.catalog(spec.type())) { Optional price = addon.getMarketService().playerBuysAt(spec, material); - price.ifPresent(unit -> buttons.add(button( - String.format("Buy %d x %s - $%.2f", BUY_BATCH, MarketService.pretty(material), - unit * BUY_BATCH), - NamedTextColor.AQUA, String.format("$%.2f each; limited by balance and hold space", unit), - () -> { - addon.getMarketService().buy(player, spec, material, BUY_BATCH); - openBuy(player, spec); - }))); + price.ifPresent(unit -> { + String name = MarketService.pretty(material); + String each = String.format("$%.2f each; limited by balance and hold space", unit); + buttons.add(button(String.format("%s x1 - $%.2f", name, unit), NamedTextColor.AQUA, each, + () -> buyThenReopen(player, spec, material, 1))); + buttons.add(button(String.format("x%d - $%.2f", MID_BATCH, unit * MID_BATCH), NamedTextColor.AQUA, + each, () -> buyThenReopen(player, spec, material, MID_BATCH))); + buttons.add(button(String.format("x%d - $%.2f", BIG_BATCH, unit * BIG_BATCH), NamedTextColor.AQUA, + each, () -> buyThenReopen(player, spec, material, BIG_BATCH))); + }); } if (buttons.isEmpty()) { User.getInstance(player).sendMessage("tradewinds.trade.nothing-for-sale"); @@ -113,7 +131,12 @@ public void openBuy(Player player, IslandSpec spec) { return; } show(player, spec.name() + " - Buying", - List.of("Goods sold into your hold", statusLine(player)), buttons, backButton(player, spec)); + List.of("Goods sold into your hold", statusLine(player)), buttons, backButton(player, spec), 3); + } + + private void buyThenReopen(Player player, IslandSpec spec, Material material, int amount) { + addon.getMarketService().buy(player, spec, material, amount); + openBuy(player, spec); } /** @@ -153,19 +176,19 @@ private ActionButton closeButton() { } private ActionButton button(String label, NamedTextColor color, String tooltip, Runnable action) { - return ActionButton.create(Component.text(label, color), Component.text(tooltip), 300, + return ActionButton.create(Component.text(label, color), Component.text(tooltip), 140, DialogAction.customClick((response, audience) -> action.run(), ClickCallback.Options.builder().build())); } private void show(Player player, String title, List bodyLines, List buttons, - ActionButton exitButton) { + ActionButton exitButton, int columns) { List body = bodyLines.stream() .map(line -> (DialogBody) DialogBody.plainMessage(Component.text(line, NamedTextColor.GRAY))) .toList(); Dialog dialog = Dialog.create(factory -> factory.empty() .base(DialogBase.builder(Component.text(title)).body(body).build()) - .type(DialogType.multiAction(buttons).exitAction(exitButton).columns(1).build())); + .type(DialogType.multiAction(buttons).exitAction(exitButton).columns(columns).build())); player.showDialog(dialog); } } From 25eb360e334617b5c3ff270b79538f9a3db56238 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 21:24:19 -0700 Subject: [PATCH 017/112] Rower navigation: hologram compass and Star Chart map - /tw chart (boated): caller-only TextDisplay holograms glide out to a 10-block ring on each charted island's true bearing, stacking nearest- lowest on shared bearings; configurable fade; /tw chart list keeps text - /tw starchart: shared contextual MapView, holder always centered, band- colored island dots with names, off-map islands pinned to the edge; map id persisted in TWWorldData and renderer re-attached after restart - 119 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 18 ++ docs/PROGRESS.md | 23 +++ .../world/bentobox/tradewinds/Settings.java | 26 +++ .../world/bentobox/tradewinds/TradeWinds.java | 20 +++ .../tradewinds/commands/TWChartCommand.java | 9 + .../commands/TWStarChartCommand.java | 35 ++++ .../tradewinds/dataobjects/TWWorldData.java | 45 +++++ .../tradewinds/travel/ChartHolograms.java | 168 ++++++++++++++++++ .../tradewinds/travel/StarChartRenderer.java | 144 +++++++++++++++ .../tradewinds/travel/StarChartService.java | 91 ++++++++++ src/main/resources/config.yml | 9 + src/main/resources/locales/en-US.yml | 6 +- .../travel/ChartNavigationTest.java | 90 ++++++++++ 13 files changed, 683 insertions(+), 1 deletion(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWStarChartCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/dataobjects/TWWorldData.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/StarChartService.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java diff --git a/TESTING.md b/TESTING.md index ac25a57..cfec567 100644 --- a/TESTING.md +++ b/TESTING.md @@ -269,3 +269,21 @@ everything else (islands, database) is untouched; the area regenerates with the still limited by balance and hold space. - [ ] More than 8 cargo types: the sell page shows the first 8 with a note; selling some reveals the rest. + +## Stage 4e — Rower navigation: chart holograms + Star Chart + +- [ ] **Hologram compass**: in a boat, `/tw chart` raises floating name-tags around you, + each hanging in the true direction of a charted island (name / type, band / + distance). They start at you and glide out to a ~10 block ring. +- [ ] Islands sharing a bearing stack vertically — nearest lowest, further ones above. +- [ ] Holograms fade after ~15s (`chart.hologram-duration-seconds`); re-running the + command replaces them; only YOU see them (check with a second account). +- [ ] Ashore (not in a boat) or with `/tw chart list`: the text list as before. +- [ ] **Star Chart**: `/tw starchart` gives a map item. Holding it: dark-blue ocean, + you centered as a rotating arrow, charted islands as band-colored dots with + names. Islands beyond the map edge are pinned AT the edge with their name — + a heading hint until you get closer. +- [ ] The chart follows you as you row (you stay centered); scale via + `chart.starchart-blocks-per-pixel` (64 = ~8km across). +- [ ] Restart the server: an existing Star Chart item still renders (the view id + persists and the renderer re-attaches). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c8b1d29..b4f2b31 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,29 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Rower navigation: hologram compass + Star Chart (2026-07-30) + +Ben: /tw chart's text list was useless for navigation; rowers (no fuel) had +no idea which way to go. Two aids, both his design: +- **Hologram compass** (`ChartHolograms`): boated `/tw chart` spawns + TextDisplay holograms on a 10-block ring in each charted island's true + bearing (name/type+band/distance), zooming out from the player via display + teleport interpolation (setTeleportDuration). Shared-bearing islands stack + nearest-lowest (20-degree sectors). Caller-only (hideEntity for others), + non-persistent, auto-fade after chart.hologram-duration-seconds. Marker + geometry is pure and tested. `/tw chart list` keeps the text list. +- **Star Chart** (`StarChartRenderer` + `StarChartService` + `TWWorldData`): + /tw starchart gives a FILLED_MAP bound to one shared contextual MapView; + the renderer draws per holder - ocean background, holder centered as a + rotating cursor, charted islands as band-colored dots (sized by real + terrain radius) with MinecraftFont names, out-of-range islands pinned to + the map edge as heading hints. Redraw throttled to 1/s or a pixel of + movement. Map id persists in TWWorldData; MapInitializeEvent re-attaches + the renderer after restarts so old items keep working. Click-to-zoom is + not possible with map renderers - scale is config + (chart.starchart-blocks-per-pixel). +119 tests green. + ## Trade quantities + empty-hold UX (2026-07-30) Ben wanted partial sells ("left-click all, right-click one"). Dialog buttons diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 75962ec..8a9e0e1 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -172,6 +172,24 @@ private static Map defaultBandHurtVillagers() { @ConfigEntry(path = "residents.audit-period-seconds") private int residentAuditPeriodSeconds = 30; + /* CHART NAVIGATION */ + @ConfigComment("Ring radius in blocks for /tw chart's direction holograms.") + @ConfigEntry(path = "chart.hologram-distance") + private double chartHologramDistance = 10.0; + + @ConfigComment("Seconds before chart holograms fade away.") + @ConfigEntry(path = "chart.hologram-duration-seconds") + private int chartHologramSeconds = 15; + + @ConfigComment("Maximum holograms shown at once (nearest islands first).") + @ConfigEntry(path = "chart.hologram-max") + private int chartHologramMax = 12; + + @ConfigComment("Star Chart map scale: blocks per map pixel. 64 shows ~8km across;") + @ConfigComment("smaller values zoom in (islands render larger).") + @ConfigEntry(path = "chart.starchart-blocks-per-pixel") + private int starChartBlocksPerPixel = 64; + /* HUD */ @ConfigComment("Show the navigation boss bar in island waters: island name, your standing,") @ConfigComment("and the distance to the dock - so you can steer for it after a warp.") @@ -2286,6 +2304,14 @@ public void setConcurrentIslands(int concurrentIslands) { public void setResidentRespawnDelayMinutes(int residentRespawnDelayMinutes) { this.residentRespawnDelayMinutes = residentRespawnDelayMinutes; } public int getResidentAuditPeriodSeconds() { return residentAuditPeriodSeconds; } public void setResidentAuditPeriodSeconds(int residentAuditPeriodSeconds) { this.residentAuditPeriodSeconds = residentAuditPeriodSeconds; } + public double getChartHologramDistance() { return chartHologramDistance; } + public void setChartHologramDistance(double chartHologramDistance) { this.chartHologramDistance = chartHologramDistance; } + public int getChartHologramSeconds() { return chartHologramSeconds; } + public void setChartHologramSeconds(int chartHologramSeconds) { this.chartHologramSeconds = chartHologramSeconds; } + public int getChartHologramMax() { return chartHologramMax; } + public void setChartHologramMax(int chartHologramMax) { this.chartHologramMax = chartHologramMax; } + public int getStarChartBlocksPerPixel() { return starChartBlocksPerPixel; } + public void setStarChartBlocksPerPixel(int starChartBlocksPerPixel) { this.starChartBlocksPerPixel = starChartBlocksPerPixel; } public boolean isNavigationBossbar() { return navigationBossbar; } public void setNavigationBossbar(boolean navigationBossbar) { this.navigationBossbar = navigationBossbar; } public double getStartingBalance() { return startingBalance; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 803d781..3e1de5e 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -28,6 +28,7 @@ import world.bentobox.tradewinds.commands.AdminTpIslandCommand; import world.bentobox.tradewinds.commands.TWChartCommand; import world.bentobox.tradewinds.commands.TWSpawnCommand; +import world.bentobox.tradewinds.commands.TWStarChartCommand; import world.bentobox.tradewinds.commands.TWTradeCommand; import world.bentobox.tradewinds.commands.TWWarpCommand; import world.bentobox.tradewinds.dataobjects.IslandDataManager; @@ -41,9 +42,11 @@ import world.bentobox.tradewinds.tasks.NavigationBarTask; import world.bentobox.tradewinds.tasks.ResidentAuditTask; import world.bentobox.tradewinds.travel.BorderPromptListener; +import world.bentobox.tradewinds.travel.ChartHolograms; import world.bentobox.tradewinds.travel.ChartingListener; import world.bentobox.tradewinds.travel.FuelService; import world.bentobox.tradewinds.travel.HoldService; +import world.bentobox.tradewinds.travel.StarChartService; import world.bentobox.tradewinds.travel.StarterKit; import world.bentobox.tradewinds.travel.WarpService; import world.bentobox.tradewinds.galaxy.GalaxyConfig; @@ -81,6 +84,8 @@ public class TradeWinds extends GameModeAddon { private IslandDataManager islandDataManager; private MarketService marketService; private TradeDialog tradeDialog; + private ChartHolograms chartHolograms; + private StarChartService starChartService; private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; @@ -117,6 +122,7 @@ public void setup() { new TWSpawnCommand(this); new TWWarpCommand(this); new TWChartCommand(this); + new TWStarChartCommand(this); new TWTradeCommand(this); new IslandInfoCommand(this); new IslandSettingsCommand(this); @@ -184,6 +190,9 @@ public void onEnable() { marketService = new MarketService(this); tradeDialog = new TradeDialog(this); registerListener(new TradeListener(this)); + chartHolograms = new ChartHolograms(this); + starChartService = new StarChartService(this); + registerListener(starChartService); registerListener(new ChartingListener(this)); registerListener(new BorderPromptListener(this)); // Residents survive the night: no mob targeting, tether, respawn @@ -209,6 +218,9 @@ public void onDisable() { if (navigationBarTask != null) { navigationBarTask.stop(); } + if (chartHolograms != null) { + chartHolograms.clearAll(); + } if (playerDataManager != null) { playerDataManager.saveAll(); } @@ -341,6 +353,14 @@ public TradeDialog getTradeDialog() { return tradeDialog; } + public ChartHolograms getChartHolograms() { + return chartHolograms; + } + + public StarChartService getStarChartService() { + return starChartService; + } + /** * The player's legal standing, for HUDs. Until the reputation system * (Stage 6) lands, everyone is Clean. diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java index ddfeee2..95c56e1 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -31,6 +31,15 @@ public void setup() { @Override public boolean execute(User user, String label, List args) { TradeWinds addon = getAddon(); + // In a boat (and not asking for the text list): raise the hologram + // compass - visual targets to row toward + boolean wantList = !args.isEmpty() && args.get(0).equalsIgnoreCase("list"); + if (!wantList && user.getPlayer().getVehicle() instanceof org.bukkit.entity.Boat + && getWorld().equals(user.getWorld())) { + addon.getChartHolograms().show(user.getPlayer()); + user.sendMessage("tradewinds.chart.holograms-shown"); + return true; + } GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); int x = getWorld().equals(user.getWorld()) ? user.getLocation().getBlockX() : 0; int z = getWorld().equals(user.getWorld()) ? user.getLocation().getBlockZ() : 0; diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWStarChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWStarChartCommand.java new file mode 100644 index 0000000..8d903c9 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWStarChartCommand.java @@ -0,0 +1,35 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Hands the player a Star Chart: a live map of their charted islands, always + * centered on the holder - the rower's navigation aid. + * + * @author tastybento + */ +public class TWStarChartCommand extends CompositeCommand { + + public TWStarChartCommand(CompositeCommand parent) { + super(parent, "starchart"); + } + + @Override + public void setup() { + setPermission("island.starchart"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.starchart.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + addon.getStarChartService().give(user.getPlayer()); + user.sendMessage("tradewinds.chart.starchart-given"); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWWorldData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWWorldData.java new file mode 100644 index 0000000..42fe31c --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWWorldData.java @@ -0,0 +1,45 @@ +package world.bentobox.tradewinds.dataobjects; + +import com.google.gson.annotations.Expose; + +import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.database.objects.Table; + +/** + * Singleton world-level state (spec §9). Currently: the star chart's map id so + * the custom renderer can be re-attached after restarts. + * + * @author tastybento + */ +@Table(name = "TWWorldData") +public class TWWorldData implements DataObject { + + public static final String KEY = "world"; + + @Expose + private String uniqueId = KEY; + + /** + * Bukkit map id of the shared Star Chart view, or -1 if never created. + */ + @Expose + private int starChartMapId = -1; + + @Override + public String getUniqueId() { + return uniqueId; + } + + @Override + public void setUniqueId(String uniqueId) { + this.uniqueId = uniqueId; + } + + public int getStarChartMapId() { + return starChartMapId; + } + + public void setStarChartMapId(int starChartMapId) { + this.starChartMapId = starChartMapId; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java new file mode 100644 index 0000000..29d8e50 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -0,0 +1,168 @@ +package world.bentobox.tradewinds.travel; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.entity.Display.Billboard; +import org.bukkit.entity.Player; +import org.bukkit.entity.TextDisplay; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * The hologram compass: {@code /tw chart} in a boat raises text holograms + * around the player, each hanging in the true direction of a charted island - + * a visual target to row toward. They zoom out from the player to a ring + * (display teleport interpolation), stack vertically when islands share a + * bearing (nearest lowest, like distance layers above the horizon), and fade + * after a few seconds. Only the caller sees them. + * + * @author tastybento + */ +public class ChartHolograms { + + /** Bearing sector width for stacking, degrees. */ + private static final double SECTOR_DEGREES = 20.0; + private static final double BASE_HEIGHT = 1.2; + private static final double STACK_STEP = 0.8; + private static final int ZOOM_TICKS = 12; + + private final TradeWinds addon; + private final Map> active = new ConcurrentHashMap<>(); + + public ChartHolograms(TradeWinds addon) { + this.addon = addon; + } + + /** + * One hologram's placement: offset from the player and its label parts. + */ + record Marker(double dx, double dy, double dz, IslandSpec island, int distance) { + } + + /** + * Compute marker placements - pure and testable. Islands sorted nearest + * first, capped; when several share a bearing sector the nearest sits at + * the base height and the further ones stack above it. + * + * @param islands charted islands + * @param px player block x + * @param pz player block z + * @param radius ring radius in blocks + * @param max maximum markers + * @return marker placements + */ + static List markers(List islands, int px, int pz, double radius, int max) { + List sorted = islands.stream() + .sorted(Comparator.comparingLong(s -> s.distanceSquared(px, pz))) + .limit(max) + .toList(); + Map sectorRank = new HashMap<>(); + List result = new ArrayList<>(); + for (IslandSpec spec : sorted) { + double dx = (double) spec.centerX() - px; + double dz = (double) spec.centerZ() - pz; + double dist = Math.max(1.0, Math.hypot(dx, dz)); + double bearing = Math.toDegrees(Math.atan2(dz, dx)); + int sector = (int) Math.floor((bearing + 180.0) / SECTOR_DEGREES); + int rank = sectorRank.merge(sector, 1, Integer::sum) - 1; + result.add(new Marker(dx / dist * radius, BASE_HEIGHT + rank * STACK_STEP, dz / dist * radius, spec, + (int) dist)); + } + return result; + } + + /** + * Show the compass to a player: clears any previous set, spawns the + * holograms at the player and zooms them out to the ring. + */ + public void show(Player player) { + clear(player.getUniqueId()); + List charted = chartedIslands(player); + if (charted.isEmpty()) { + return; + } + Location eye = player.getLocation(); + List markers = markers(charted, eye.getBlockX(), eye.getBlockZ(), + addon.getSettings().getChartHologramDistance(), addon.getSettings().getChartHologramMax()); + List spawned = new ArrayList<>(); + for (Marker marker : markers) { + TextDisplay display = eye.getWorld().spawn(eye.clone().add(0, 1.0, 0), TextDisplay.class); + display.text(label(marker)); + display.setBillboard(Billboard.CENTER); + display.setSeeThrough(true); + display.setBackgroundColor(Color.fromARGB(120, 0, 20, 40)); + display.setPersistent(false); + display.setTeleportDuration(ZOOM_TICKS); + // Only the caller sees their own compass + for (Player other : Bukkit.getOnlinePlayers()) { + if (!other.equals(player)) { + other.hideEntity(addon.getPlugin(), display); + } + } + spawned.add(display); + // Next tick: glide out to the ring position + Location target = eye.clone().add(marker.dx(), marker.dy(), marker.dz()); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { + if (display.isValid()) { + display.teleport(target); + } + }); + } + active.put(player.getUniqueId(), spawned); + // Fade out after the configured time + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> clear(player.getUniqueId()), + addon.getSettings().getChartHologramSeconds() * 20L); + } + + private Component label(Marker marker) { + IslandSpec spec = marker.island(); + return Component.text(spec.name(), NamedTextColor.AQUA) + .append(Component.newline()) + .append(Component.text(spec.type().name() + ", " + spec.band().getDisplayName(), + NamedTextColor.GRAY)) + .append(Component.newline()) + .append(Component.text(marker.distance() + "m", NamedTextColor.YELLOW)); + } + + private List chartedIslands(Player player) { + var engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + return addon.getPlayerDataManager().get(player.getUniqueId()).getChartedIslands().stream() + .map(key -> key.split(",")) + .map(cell -> engine.islandInCell(Integer.parseInt(cell[0]), Integer.parseInt(cell[1]))) + .flatMap(java.util.Optional::stream) + .toList(); + } + + /** + * Remove a player's holograms. + */ + public void clear(UUID playerId) { + List displays = active.remove(playerId); + if (displays != null) { + displays.forEach(display -> { + if (display.isValid()) { + display.remove(); + } + }); + } + } + + /** + * Remove everything (disable). + */ + public void clearAll() { + List.copyOf(active.keySet()).forEach(this::clear); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java b/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java new file mode 100644 index 0000000..6ddbda4 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java @@ -0,0 +1,144 @@ +package world.bentobox.tradewinds.travel; + +import java.awt.Color; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.bukkit.map.MapCanvas; +import org.bukkit.map.MapCursor; +import org.bukkit.map.MapCursorCollection; +import org.bukkit.map.MapRenderer; +import org.bukkit.map.MapView; +import org.bukkit.map.MinecraftFont; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * The Star Chart: a contextual map renderer that keeps the holder centered and + * plots their charted islands - dots sized by the real island footprint, + * names beside them, and islands beyond the map's reach pinned to the edge so + * you learn which way the rest of the world lies. Scale is config + * (blocks-per-pixel); redraws at most once a second per holder. + * + * @author tastybento + */ +public class StarChartRenderer extends MapRenderer { + + private static final Color OCEAN = new Color(12, 44, 84); + private static final Color EDGE = new Color(120, 120, 120); + + private final TradeWinds addon; + private final Map lastDraw = new HashMap<>(); + private final Map lastPos = new HashMap<>(); + + public StarChartRenderer(TradeWinds addon) { + super(true); // contextual: rendered per player + this.addon = addon; + } + + /** + * World offset to canvas pixel (center 64,64), or edge-clamped when out of + * range. Pure and testable. + * + * @param dx block offset east of the holder + * @param dz block offset south of the holder + * @param blocksPerPixel map scale + * @return {pixelX, pixelZ, onEdge(0/1)} + */ + static int[] toPixel(long dx, long dz, int blocksPerPixel) { + long px = dx / blocksPerPixel; + long pz = dz / blocksPerPixel; + boolean edge = Math.abs(px) > 60 || Math.abs(pz) > 60; + if (edge) { + double scale = 60.0 / Math.max(Math.abs(px), Math.abs(pz)); + px = Math.round(px * scale); + pz = Math.round(pz * scale); + } + return new int[] { (int) (64 + px), (int) (64 + pz), edge ? 1 : 0 }; + } + + @Override + public void render(MapView view, MapCanvas canvas, Player player) { + if (addon.getOverWorld() == null || !player.getWorld().equals(addon.getOverWorld())) { + return; + } + // Redraw once a second, or when the holder has moved a pixel's worth + long now = System.currentTimeMillis(); + int bpp = Math.max(1, addon.getSettings().getStarChartBlocksPerPixel()); + int px = player.getLocation().getBlockX(); + int pz = player.getLocation().getBlockZ(); + int[] last = lastPos.get(player.getUniqueId()); + boolean moved = last == null || Math.abs(px - last[0]) >= bpp || Math.abs(pz - last[1]) >= bpp; + if (!moved && now - lastDraw.getOrDefault(player.getUniqueId(), 0L) < 1000) { + return; + } + lastDraw.put(player.getUniqueId(), now); + lastPos.put(player.getUniqueId(), new int[] { px, pz }); + + // Ocean background + for (int x = 0; x < 128; x++) { + for (int z = 0; z < 128; z++) { + canvas.setPixelColor(x, z, OCEAN); + } + } + // Charted islands + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + int islandPixelRadius = Math.max(1, engine.getConfig().terrainRadius() / bpp); + for (String key : addon.getPlayerDataManager().get(player.getUniqueId()).getChartedIslands()) { + String[] cell = key.split(","); + engine.islandInCell(Integer.parseInt(cell[0]), Integer.parseInt(cell[1])).ifPresent(spec -> { + int[] pixel = toPixel(spec.centerX() - (long) px, spec.centerZ() - (long) pz, bpp); + if (pixel[2] == 1) { + // Beyond the chart: pin the name to the edge as a heading hint + canvas.setPixelColor(pixel[0], pixel[1], EDGE); + drawName(canvas, pixel[0], pixel[1], spec.name()); + } else { + Color color = bandColor(spec.band()); + fillDot(canvas, pixel[0], pixel[1], islandPixelRadius, color); + drawName(canvas, pixel[0], pixel[1], spec.name()); + } + }); + } + // The holder: a cursor arrow at center, rotating with their facing + MapCursorCollection cursors = new MapCursorCollection(); + byte direction = (byte) (Math.round(player.getLocation().getYaw() * 16.0 / 360.0) & 15); + cursors.addCursor(new MapCursor((byte) 0, (byte) 0, direction, MapCursor.Type.PLAYER, true)); + canvas.setCursors(cursors); + } + + private void fillDot(MapCanvas canvas, int cx, int cz, int radius, Color color) { + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + if (x * x + z * z <= radius * radius) { + int pixelX = cx + x; + int pixelZ = cz + z; + if (pixelX >= 0 && pixelX < 128 && pixelZ >= 0 && pixelZ < 128) { + canvas.setPixelColor(pixelX, pixelZ, color); + } + } + } + } + } + + private void drawName(MapCanvas canvas, int x, int z, String name) { + int width = MinecraftFont.Font.getWidth(name); + int textX = Math.clamp(x - width / 2, 1, 127 - width); + int textZ = Math.clamp(z + 3, 1, 119); + canvas.drawText(textX, textZ, MinecraftFont.Font, name); + } + + static Color bandColor(SecurityBand band) { + return switch (band) { + case SAFE -> new Color(80, 180, 255); + case POLICED -> new Color(90, 200, 90); + case FRONTIER -> new Color(230, 200, 60); + case LAWLESS -> new Color(220, 90, 60); + case ANARCHIC -> new Color(190, 80, 220); + }; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java b/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java new file mode 100644 index 0000000..df4651d --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java @@ -0,0 +1,91 @@ +package world.bentobox.tradewinds.travel; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.MapInitializeEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.MapMeta; +import org.bukkit.map.MapView; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.database.Database; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.TWWorldData; + +/** + * Hands out the Star Chart: a shared contextual MapView rendered per holder by + * {@link StarChartRenderer}. The view's id persists in TWWorldData so the + * renderer re-attaches on MapInitializeEvent after restarts - old chart items + * keep working forever. + * + * @author tastybento + */ +public class StarChartService implements Listener { + + private final TradeWinds addon; + private final Database handler; + private TWWorldData data; + private MapView view; + + public StarChartService(TradeWinds addon) { + this.addon = addon; + this.handler = new Database<>(addon, TWWorldData.class); + this.data = handler.objectExists(TWWorldData.KEY) ? handler.loadObject(TWWorldData.KEY) : null; + if (data == null) { + data = new TWWorldData(); + } + } + + /** + * Give (or hand another copy of) the Star Chart to a player. + */ + public void give(Player player) { + ItemStack item = new ItemStack(Material.FILLED_MAP); + if (item.getItemMeta() instanceof MapMeta meta) { + meta.setMapView(chartView()); + meta.displayName(Component.text("Star Chart", NamedTextColor.AQUA)); + item.setItemMeta(meta); + } + player.getInventory().addItem(item).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + } + + private MapView chartView() { + if (view == null) { + if (data.getStarChartMapId() >= 0) { + view = Bukkit.getMap(data.getStarChartMapId()); + } + if (view == null) { + view = Bukkit.createMap(addon.getOverWorld()); + data.setStarChartMapId(view.getId()); + handler.saveObjectAsync(data); + } + attachRenderer(view); + } + return view; + } + + private void attachRenderer(MapView mapView) { + mapView.getRenderers().forEach(mapView::removeRenderer); + mapView.addRenderer(new StarChartRenderer(addon)); + mapView.setScale(MapView.Scale.FARTHEST); + mapView.setTrackingPosition(false); + mapView.setUnlimitedTracking(false); + mapView.setLocked(true); + } + + /** + * After a restart, old Star Chart items re-initialize their view here. + */ + @EventHandler + public void onMapInitialize(MapInitializeEvent event) { + if (data.getStarChartMapId() >= 0 && event.getMap().getId() == data.getStarChartMapId()) { + view = event.getMap(); + attachRenderer(view); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ef48253..1160b32 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -114,6 +114,15 @@ residents: respawn-delay-minutes: 10 # Seconds between resident audits (tether check + respawn accounting). audit-period-seconds: 30 +chart: + # Ring radius in blocks for /tw chart's direction holograms. + hologram-distance: 10.0 + # Seconds before chart holograms fade away. + hologram-duration-seconds: 15 + # Maximum holograms shown at once (nearest islands first). + hologram-max: 12 + # Star Chart map scale: blocks per map pixel. 64 shows ~8km across. + starchart-blocks-per-pixel: 64 hud: # Navigation boss bar in island waters: island name, standing, distance to dock. navigation-bossbar: true diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 2a8f02a..e00be5b 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -17,7 +17,9 @@ tradewinds: warp: description: "open the warp dialog (in a boat, at an island)" chart: - description: "list the islands on your chart" + description: "show your chart - holograms afloat, a list ashore" + starchart: + description: "receive a Star Chart map of your charted islands" trade: description: "open the island market (within its protection range)" admin: @@ -44,6 +46,8 @@ tradewinds: clean: "Clean" chart: charted: "Charted [name]!" + holograms-shown: "&b Your chart shimmers into the air - row toward a name." + starchart-given: "&b The Star Chart is yours - hold it to see your world." empty: "&c Your chart is empty - row into an island's waters to chart it." header: "&b Your chart ([number] islands):" entry: "&e [name] &7- [type], [band], [distance] blocks" diff --git a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java new file mode 100644 index 0000000..1db8109 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java @@ -0,0 +1,90 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Pure tests of the rower navigation math: hologram marker geometry and star + * chart pixel mapping. + * + * @author tastybento + */ +class ChartNavigationTest { + + private final GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(77L, 2500, 160, 45, 1.0, 0, 5000, 70)); + + @Test + void testMarkersPointTheRightWay() { + IslandSpec spec = engine.islandInCell(0, 0).orElseThrow(); + List markers = ChartHolograms.markers(List.of(spec), 0, 0, 10.0, 12); + assertEquals(1, markers.size()); + ChartHolograms.Marker marker = markers.get(0); + // On the 10-block ring + assertEquals(10.0, Math.hypot(marker.dx(), marker.dz()), 0.01); + // Pointing at the island: same bearing as the island itself + double markerBearing = Math.atan2(marker.dz(), marker.dx()); + double islandBearing = Math.atan2(spec.centerZ(), spec.centerX()); + assertEquals(islandBearing, markerBearing, 0.001); + assertEquals((int) Math.hypot(spec.centerX(), spec.centerZ()), marker.distance()); + } + + @Test + void testSharedBearingStacksByDistance() { + // Two islands due east at different ranges: nearest sits lowest + IslandSpec near = new IslandSpec(0, 0, 3000, 0, world.bentobox.tradewinds.galaxy.IslandType.FISHING, + SecurityBand.SAFE, "minecraft:beach", "Nearby"); + IslandSpec far = new IslandSpec(1, 0, 8000, 0, world.bentobox.tradewinds.galaxy.IslandType.MINING, + SecurityBand.SAFE, "minecraft:stony_peaks", "Distant"); + List markers = ChartHolograms.markers(List.of(far, near), 0, 0, 10.0, 12); + assertEquals("Nearby", markers.get(0).island().name()); + assertTrue(markers.get(0).dy() < markers.get(1).dy(), + "Nearest island should sit below the further one on the same bearing"); + } + + @Test + void testMarkerCap() { + List many = new java.util.ArrayList<>(); + for (int cx = -3; cx <= 3; cx++) { + for (int cz = -3; cz <= 3; cz++) { + engine.islandInCell(cx, cz).ifPresent(many::add); + } + } + assertTrue(many.size() > 12); + assertEquals(12, ChartHolograms.markers(many, 0, 0, 10.0, 12).size()); + } + + @Test + void testStarChartPixels() { + // On-map island: simple scaled offset from center + int[] pixel = StarChartRenderer.toPixel(640, -1280, 64); + assertEquals(64 + 10, pixel[0]); + assertEquals(64 - 20, pixel[1]); + assertEquals(0, pixel[2]); + // Beyond the map: clamped to the edge, flagged + int[] edge = StarChartRenderer.toPixel(64 * 200, 0, 64); + assertEquals(1, edge[2]); + assertEquals(64 + 60, edge[0]); + assertEquals(64, edge[1]); + // Diagonal clamp keeps direction + int[] diag = StarChartRenderer.toPixel(64 * 200, 64 * 200, 64); + assertEquals(1, diag[2]); + assertEquals(64 + 60, diag[0]); + assertEquals(64 + 60, diag[1]); + } + + @Test + void testBandColorsDistinct() { + long distinct = java.util.Arrays.stream(SecurityBand.values()).map(StarChartRenderer::bandColor).distinct() + .count(); + assertEquals(SecurityBand.values().length, distinct); + } +} From d1df6ae578b9d5f42cc6adad01f6ca1672b79be9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 21:36:07 -0700 Subject: [PATCH 018/112] Starter bundle carries a little coal so the first hop can be a warp economy.starter-coal (default 8 = 64 fuel units) goes inside the trading bundle, which is hold space - warp-usable from the starter rowboat. 119 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- src/main/java/world/bentobox/tradewinds/Settings.java | 7 +++++++ .../java/world/bentobox/tradewinds/travel/StarterKit.java | 6 ++++++ src/main/resources/config.yml | 2 ++ src/main/resources/locales/en-US.yml | 2 +- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 8a9e0e1..cf445d1 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -270,6 +270,11 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "economy.starting-balance") private double startingBalance = 250.0; + @ConfigComment("Coal tucked into the starter bundle - enough fuel that the first island hop") + @ConfigComment("can be a warp instead of a seven-minute row. 0 disables.") + @ConfigEntry(path = "economy.starter-coal") + private int starterCoal = 8; + @ConfigComment("Multiplier on what players PAY an island (buying).") @ConfigEntry(path = "economy.buy-spread") private double buySpread = 1.15; @@ -2316,6 +2321,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setNavigationBossbar(boolean navigationBossbar) { this.navigationBossbar = navigationBossbar; } public double getStartingBalance() { return startingBalance; } public void setStartingBalance(double startingBalance) { this.startingBalance = startingBalance; } + public int getStarterCoal() { return starterCoal; } + public void setStarterCoal(int starterCoal) { this.starterCoal = starterCoal; } public double getBuySpread() { return buySpread; } public void setBuySpread(double buySpread) { this.buySpread = buySpread; } public double getSellSpread() { return sellSpread; } diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java index 04d7085..83e2c9f 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java @@ -75,6 +75,12 @@ private ItemStack tradingBundle() { ItemMeta meta = bundle.getItemMeta(); if (meta != null) { meta.displayName(Component.text("Trading Bundle", NamedTextColor.GOLD)); + // A little coal in the bundle: it is hold cargo, so the first + // island hop can be a warp instead of a long row + int coal = addon.getSettings().getStarterCoal(); + if (coal > 0 && meta instanceof org.bukkit.inventory.meta.BundleMeta bundleMeta) { + bundleMeta.setItems(java.util.List.of(new ItemStack(Material.COAL, coal))); + } bundle.setItemMeta(meta); } return bundle; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1160b32..85228b0 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -129,6 +129,8 @@ hud: economy: # Money given to brand-new players with their starter kit. starting-balance: 250.0 + # Coal tucked into the starter bundle - fuel for a first warp. 0 disables. + starter-coal: 8 # Multiplier on what players PAY an island (buying) / what an island PAYS players (selling). # Together these make a same-island round trip always lose money. buy-spread: 1.15 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index e00be5b..40e092a 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -41,7 +41,7 @@ tradewinds: spawn: teleported: "&b You set sail on the open ocean." starter-kit: - given: "&6 Your trading career begins: a boat and a trading bundle. Buy low, sell high." + given: "&6 Your trading career begins: a boat, a trading bundle, and a little coal. Buy low, sell high." status: clean: "Clean" chart: From 09f894104611b05ccceb5fb79ec2afad94bed4ff Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 21:39:58 -0700 Subject: [PATCH 019/112] Fuel guarantee: every island sells fuel, charcoal as the fallback saleCatalog appends CHARCOAL when a type's catalog has no fuel-valued goods; charcoal reclassified as WOOD (cheap at FOREST, distinct from mined COAL). Only the skint and fuel-less row. 121 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 7 ++ docs/PROGRESS.md | 10 +++ .../tradewinds/economy/MarketService.java | 16 +++++ .../tradewinds/economy/TradeCategory.java | 6 +- .../tradewinds/economy/TradeDialog.java | 6 +- src/main/resources/config.yml | 1 + .../tradewinds/economy/SaleCatalogTest.java | 71 +++++++++++++++++++ .../tradewinds/economy/TradeCategoryTest.java | 2 + 8 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java diff --git a/TESTING.md b/TESTING.md index cfec567..569ec79 100644 --- a/TESTING.md +++ b/TESTING.md @@ -287,3 +287,10 @@ everything else (islands, database) is untouched; the area regenerates with the `chart.starchart-blocks-per-pixel` (64 = ~8km across). - [ ] Restart the server: an existing Star Chart item still renders (the view id persists and the renderer re-attaches). + +### Stage 4f — the fuel guarantee +- [ ] Every island's Buy page includes at least one fuel: FOREST sells logs (and + charcoal is classified as wood, so forest charcoal is cheap), MINING sells coal, + and everywhere else — including LUXURY, which otherwise sells nothing — CHARCOAL + appears as the fallback line, priced like any commodity. +- [ ] With money but no fuel, you can always refuel at whatever island you are at. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b4f2b31..c76fb48 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,16 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The fuel guarantee (2026-07-30) + +Ben's rule: only the skint AND fuel-less row. `MarketService.saleCatalog` +wraps the type catalog and appends CHARCOAL when it carries no fuel-valued +material - so LUXURY (sells nothing) and AGRICULTURAL/FISHING/FROZEN all +offer charcoal, while FOREST (logs) and MINING (coal) already qualify. +Charcoal reclassified ORES -> WOOD (charred timber; forest islands sell it +cheap, differentiates from mined coal). Priced through the normal model. +Tested per type. 121 tests green. + ## Rower navigation: hologram compass + Star Chart (2026-07-30) Ben: /tw chart's text list was useless for navigation; rowers (no fuel) had diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index c13d44e..8ef8ea5 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -46,6 +46,22 @@ public PriceEngine getPriceEngine() { return priceEngine; } + /** + * What this island offers for sale: its type's catalog, with a fuel + * guarantee - if the catalog carries no warp fuel, CHARCOAL is added, so + * anyone with money can always buy their way off rowing. Only the skint + * and fuel-less row. + */ + public java.util.List saleCatalog(IslandSpec spec) { + java.util.List catalog = new java.util.ArrayList<>(TypeEconomy.catalog(spec.type())); + boolean hasFuel = catalog.stream() + .anyMatch(m -> addon.getSettings().getFuelValues().getOrDefault(m.name(), 0.0) > 0); + if (!hasFuel) { + catalog.add(Material.CHARCOAL); + } + return catalog; + } + /** * The configured price model. */ diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java index 1055135..0e0a2e7 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java @@ -37,11 +37,11 @@ public static TradeCategory of(Material material) { return FOOD; } if (name.endsWith("_log") || name.endsWith("_planks") || name.endsWith("_wood") || name.contains("stripped") - || name.contains("sapling") || name.equals("stick") || name.contains("bamboo")) { + || name.contains("sapling") || name.equals("stick") || name.contains("bamboo") + || name.equals("charcoal")) { return WOOD; } - if (name.contains("raw_") || name.endsWith("_ore") || name.equals("coal") || name.equals("charcoal") - || name.contains("flint")) { + if (name.contains("raw_") || name.endsWith("_ore") || name.equals("coal") || name.contains("flint")) { return ORES; } if (name.endsWith("_ingot") || name.endsWith("_block") && (name.contains("iron") || name.contains("copper") diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 633439d..001d3ef 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -53,8 +53,8 @@ public void openMain(Player player, IslandSpec spec) { buttons.add(button("Sell cargo", NamedTextColor.YELLOW, "Sell your hold's goods here", () -> openSell(player, spec))); } - if (!TypeEconomy.catalog(spec.type()).isEmpty()) { - buttons.add(button("Buy goods", NamedTextColor.AQUA, "Buy this island's produce into your hold", + if (!addon.getMarketService().saleCatalog(spec).isEmpty()) { + buttons.add(button("Buy goods", NamedTextColor.AQUA, "Buy this island's goods into your hold", () -> openBuy(player, spec))); } int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); @@ -112,7 +112,7 @@ private void sellThenReopen(Player player, IslandSpec spec, Material material, i */ public void openBuy(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); - for (Material material : TypeEconomy.catalog(spec.type())) { + for (Material material : addon.getMarketService().saleCatalog(spec)) { Optional price = addon.getMarketService().playerBuysAt(spec, material); price.ifPresent(unit -> { String name = MarketService.pretty(material); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 85228b0..7be315f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -168,6 +168,7 @@ economy: DARK_OAK_LOG: 1.5 STONE: 0.5 COAL: 4.0 + CHARCOAL: 3.0 RAW_IRON: 6.0 RAW_COPPER: 3.0 RAW_GOLD: 12.0 diff --git a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java new file mode 100644 index 0000000..339c78b --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java @@ -0,0 +1,71 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.bukkit.Material; +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.TradeWinds; +import world.bentobox.tradewinds.dataobjects.IslandDataManager; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.IslandType; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * The fuel guarantee: every island type sells at least one warp fuel, so + * anyone with money can always buy their way off rowing. + * + * @author tastybento + */ +class SaleCatalogTest extends CommonTestSetup { + + private TradeWinds addon; + private MarketService service; + private Settings settings; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + when(addon.getIslandDataManager()).thenReturn(mock(IslandDataManager.class)); + service = new MarketService(addon); + } + + private IslandSpec spec(IslandType type) { + return new IslandSpec(0, 0, 2500, 2500, type, SecurityBand.SAFE, "minecraft:plains", "Test"); + } + + @Test + void testEveryIslandTypeSellsFuel() { + for (IslandType type : IslandType.values()) { + List catalog = service.saleCatalog(spec(type)); + assertTrue(catalog.stream() + .anyMatch(m -> settings.getFuelValues().getOrDefault(m.name(), 0.0) > 0), + type + " must sell some kind of fuel"); + assertTrue(!catalog.isEmpty(), type + " catalog must never be empty"); + } + } + + @Test + void testCharcoalIsTheFallbackNotTheRule() { + // FOREST sells logs (fuel) already - no charcoal forced in + assertTrue(!service.saleCatalog(spec(IslandType.FOREST)).contains(Material.CHARCOAL)); + // MINING sells coal - no charcoal forced in + assertTrue(!service.saleCatalog(spec(IslandType.MINING)).contains(Material.CHARCOAL)); + // LUXURY produces nothing: charcoal appears as the guaranteed fuel + assertTrue(service.saleCatalog(spec(IslandType.LUXURY)).contains(Material.CHARCOAL)); + // AGRICULTURAL produce (crops/food) has no fuel: charcoal appears + assertTrue(service.saleCatalog(spec(IslandType.AGRICULTURAL)).contains(Material.CHARCOAL)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java b/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java index b3e9f06..7862638 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java @@ -27,6 +27,8 @@ void testClassification() { assertEquals(TradeCategory.STONE, TradeCategory.of(Material.GRANITE)); assertEquals(TradeCategory.ORES, TradeCategory.of(Material.RAW_IRON)); assertEquals(TradeCategory.ORES, TradeCategory.of(Material.COAL)); + // Charcoal is charred timber, not a mined ore - FOREST islands sell it cheap + assertEquals(TradeCategory.WOOD, TradeCategory.of(Material.CHARCOAL)); assertEquals(TradeCategory.METALS, TradeCategory.of(Material.IRON_INGOT)); assertEquals(TradeCategory.GEMS, TradeCategory.of(Material.DIAMOND)); assertEquals(TradeCategory.LUXURY, TradeCategory.of(Material.CAKE)); From 336dea7395267f9259bcbab7652a9e4b6a88ce1a Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:08:22 -0700 Subject: [PATCH 020/112] Two economies: customs stamps, outfitters, wild islets, career restart - Customs stamp on all trader-bought goods (PDC + lore, optional glint); traders buy stamped goods only, with configured unstamped exceptions (SUGAR) while illegal trade is enabled; hold accounting predicate-aware - Outfitter shelf at every market: bread always, charcoal fuel fallback, per-type gear (smiths arm you at INDUSTRIAL, beds at farms, rods at fisheries); prices recipe-derived; fits the dialog (tested) - Wild islets seeded into empty galaxy cells (deterministic, vanilla biomes); all protection flags default-allowed outside named islands - open ocean and wild islets are free Minecraft country - /tw restart: confirmable, capped career reset keeping the chart - Spec gains 5.0 The two economies - 126 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 23 +++++ TRADEWINDS_SPEC.md | 25 ++++++ docs/PROGRESS.md | 28 +++++++ .../world/bentobox/tradewinds/Settings.java | 33 ++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 15 +++- .../tradewinds/commands/TWRestartCommand.java | 58 +++++++++++++ .../tradewinds/dataobjects/TWPlayerData.java | 14 ++++ .../tradewinds/economy/MarketService.java | 82 +++++++++++++++--- .../tradewinds/economy/TradeDialog.java | 30 ++++--- .../tradewinds/economy/TypeEconomy.java | 21 +++++ .../tradewinds/galaxy/GalaxyConfig.java | 21 ++++- .../tradewinds/galaxy/GalaxyEngine.java | 83 +++++++++++++++++++ .../tradewinds/travel/HoldService.java | 45 ++++++++-- src/main/resources/config.yml | 13 +++ src/main/resources/locales/en-US.yml | 7 ++ .../tradewinds/economy/CustomsStampTest.java | 74 +++++++++++++++++ .../tradewinds/economy/SaleCatalogTest.java | 40 ++++++--- .../tradewinds/galaxy/GalaxyEngineTest.java | 34 ++++++++ 18 files changed, 597 insertions(+), 49 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWRestartCommand.java create mode 100644 src/test/java/world/bentobox/tradewinds/economy/CustomsStampTest.java diff --git a/TESTING.md b/TESTING.md index 569ec79..b94320c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -294,3 +294,26 @@ everything else (islands, database) is untouched; the area regenerates with the and everywhere else — including LUXURY, which otherwise sells nothing — CHARCOAL appears as the fallback line, priced like any commodity. - [ ] With money but no fuel, you can always refuel at whatever island you are at. + +## Stage 4g — Two economies: stamps, outfitters, wild islets, restart + +**World note**: wild islets appear only in freshly generated chunks (empty cells you +have not visited). The protection flip and stamps work immediately. + +- [ ] **Customs stamp**: goods bought from any market carry a lore line "⚓ Customs + Stamped" (PDC-marked; `economy.stamp-glint: true` adds a glint). Stamped and + identical unstamped items never stack together. +- [ ] Only stamped goods appear on the Sell page. Homegrown wheat/mined ore in the + hold: not offered, and "traders only buy customs-stamped goods" if forced. +- [ ] **Exception**: unstamped SUGAR sells fine (the smallholder economy) — unless + `illegal-trade.enabled: false`. +- [ ] **Outfitter** button at every market: bread always; charcoal where the trade + catalog has no fuel; INDUSTRIAL sells iron sword/shield/armor; farms sell beds; + FISHING sells rods; MINING an iron pickaxe; LUXURY golden apples. All priced by + the engine (gear prices derive from their recipes). +- [ ] **Wild islets**: row between trading islands through fresh ocean — small + unnamed islands (~140 across) in varied vanilla biomes, no dock/market/name + announcement. You can break/place/farm/sleep there and in open ocean + (protection flipped outside named islands). Trading islands remain protected. +- [ ] **/tw restart**: confirmation, then balance resets to starting, fresh kit at + spawn, chart KEPT; counter decrements (3 by default); refuses at 0. diff --git a/TRADEWINDS_SPEC.md b/TRADEWINDS_SPEC.md index eec23ab..9b52500 100644 --- a/TRADEWINDS_SPEC.md +++ b/TRADEWINDS_SPEC.md @@ -94,6 +94,31 @@ Free. The border (Border addon in passable/visual mode, or protection-range visu ## 5. Economy +### 5.0 The two economies (adopted 2026-07-30) + +TradeWinds runs **two parallel economies** connected at only three points: + +- **The stamped trade economy (money).** Goods bought from traders carry a + **customs stamp** (PDC key `tradewinds:stamp` + lore line; API-visible). + Traders buy ONLY stamped goods - money enters the game exclusively through + trade margins on goods that money already bought. Farming cannot mint money. +- **The vanilla survival economy (stuff).** **Wild islets** (small unnamed + islands seeded into empty galaxy cells; `galaxy.wild-islet-*`) are free + country: mine, farm, build, sleep. All protection flags default to allowed + outside named islands' protection ranges. Homemade goods are freely usable + (eat, wear, build, burn as warp fuel) but unsellable. +- **The connections:** buying (money->stuff, stamped); **contraband** + (stuff->money: unstamped sugar - and later villagers - are the only + farmable income, balanced by scan risk per principle 3); and self-supply + (homemade food/fuel substitute for purchases). + +Survival needs are met by the **outfitter** shelf every island guarantees +(bread always, charcoal when the trade catalog carries no fuel, gear by type: +smiths at INDUSTRIAL, beds at farms, rods at fisheries) and by wild-islet +living. Destitution has an exit: `/tw restart` (config-capped) resets balance +and kit, keeping the chart. Wild islets are the future claim targets for +Stage 7 player islands (teams + Bank addon). + - **BlueBook** (`~/git/bluebook`, addon name `BlueBook`) supplies base prices via `PriceEngine.getPrice(ItemStack, worldName)` (single price; TradeWinds derives buy/sell spread). Soft-depend; fail gracefully with a config fallback table if absent. - **Per-island modifiers:** island type (farm sells food cheap, wants raw materials…), biome, security band (margins scale with danger), plus per-island **stock and price drift** persisted via the BentoBox Database (keyed by island). Everything in Minecraft is tradeable; players may sell found/crafted goods into the same market. - **Trade GUI** at the dock (villager interaction or dock sign), transacting only against the hold (§4). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index c76fb48..96ace64 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,34 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The two economies: stamps, outfitters, wild islets, restart (2026-07-30) + +Ben's survival-design discussion (players died gearless and hungry; farming +must not mint money). Adopted into the spec as §5.0: +- **Customs stamp**: MarketService.stamp() on every purchase (PDC + tradewinds:stamp + lore, optional glint). sellableFilter() = stamped OR + configured unstamped exceptions (SUGAR) while illegal-trade is enabled. + HoldService went predicate-aware (count/remove/contents filter overloads + threading through shulkers and bundles). Different lore = stamped and + homemade stacks never merge. +- **Outfitter** (second buy page): bread always, charcoal when the trade + catalog lacks fuel (fuel guarantee moved here), per-type gear + (TypeEconomy.OUTFITTER_EXTRAS) - INDUSTRIAL arms you, farms sell beds, + fisheries rods, MINING a pickaxe. Gear prices derive from recipes (the + embedded engine pays off). Shelf capped at 8 (dialog fits, tested). +- **Wild islets**: empty galaxy cells roll small unnamed islands + (galaxy.wild-islet-chance 0.3, radius 70; pure engine math, deterministic, + never in a trading-island cell, tests). Vanilla wild biomes. All PROTECTION + flags default-allowed outside island protection ranges (blanket + setDefaultSetting loop) - open ocean and wild islets are free country; + future Stage 7 claim targets. +- **/tw restart** (ConfirmableCommand): zero balance -> fresh kit at spawn + (starter deposit restores starting balance), chart kept, restartsUsed + capped by player.max-restarts (3). +- Starting money note: already existed ($250 with the kit); Ben's account + predates Stage 4 so never saw it. +126 tests green. + ## The fuel guarantee (2026-07-30) Ben's rule: only the skint AND fuel-less row. `MarketService.saleCatalog` diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index cf445d1..8a1916d 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -100,6 +100,15 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.spawn-islet-radius", needsReset = true) private int spawnIsletRadius = 48; + @ConfigComment("Chance (0-1) that a galaxy cell WITHOUT a trading island hosts a wild islet -") + @ConfigComment("small unnamed islands, unprotected: mine, farm, build, live. Minecraft-stuff land.") + @ConfigEntry(path = "galaxy.wild-islet-chance", needsReset = true) + private double wildIsletChance = 0.3; + + @ConfigComment("Terrain radius of wild islets. 0 disables them.") + @ConfigEntry(path = "galaxy.wild-islet-radius", needsReset = true) + private int wildIsletRadius = 70; + @ConfigComment("Relative spawn weight per island type. Higher = more common; 0 disables a type.") @ConfigComment("Types: AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN.") @ConfigEntry(path = "galaxy.type-weights", needsReset = true) @@ -326,6 +335,20 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "economy.max-bundles") private int maxBundles = 3; + @ConfigComment("Give customs-stamped goods an enchantment glint as well as their lore line.") + @ConfigEntry(path = "economy.stamp-glint") + private boolean stampGlint = false; + + @ConfigComment("Materials traders buy even WITHOUT a customs stamp - the contraband exceptions.") + @ConfigComment("Only honored while illegal-trade.enabled is true.") + @ConfigEntry(path = "economy.unstamped-sellables") + private List unstampedSellables = new ArrayList<>(List.of("SUGAR")); + + @ConfigComment("Career restarts a destitute player may use (/tw restart): fresh kit, starting") + @ConfigComment("balance, chart kept. -1 = unlimited, 0 = none.") + @ConfigEntry(path = "player.max-restarts") + private int maxRestarts = 3; + @ConfigComment("Base prices (Material -> price). Anything not listed is priced by deriving") @ConfigComment("from its crafting recipe (BlueBook logic, embedded); underivable = untradeable.") @ConfigEntry(path = "economy.base-prices") @@ -2273,6 +2296,10 @@ public void setConcurrentIslands(int concurrentIslands) { public void setBandRadius(int bandRadius) { this.bandRadius = bandRadius; } public int getSpawnIsletRadius() { return spawnIsletRadius; } public void setSpawnIsletRadius(int spawnIsletRadius) { this.spawnIsletRadius = spawnIsletRadius; } + public double getWildIsletChance() { return wildIsletChance; } + public void setWildIsletChance(double wildIsletChance) { this.wildIsletChance = wildIsletChance; } + public int getWildIsletRadius() { return wildIsletRadius; } + public void setWildIsletRadius(int wildIsletRadius) { this.wildIsletRadius = wildIsletRadius; } public Map getTypeWeights() { return typeWeights; } public void setTypeWeights(Map typeWeights) { this.typeWeights = typeWeights; } public double getFuelPerBlock() { return fuelPerBlock; } @@ -2347,6 +2374,12 @@ public void setConcurrentIslands(int concurrentIslands) { public void setExpanderCap(int expanderCap) { this.expanderCap = expanderCap; } public int getMaxBundles() { return maxBundles; } public void setMaxBundles(int maxBundles) { this.maxBundles = maxBundles; } + public boolean isStampGlint() { return stampGlint; } + public void setStampGlint(boolean stampGlint) { this.stampGlint = stampGlint; } + public List getUnstampedSellables() { return unstampedSellables; } + public void setUnstampedSellables(List unstampedSellables) { this.unstampedSellables = unstampedSellables; } + public int getMaxRestarts() { return maxRestarts; } + public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } public Map getBasePrices() { return basePrices; } public void setBasePrices(Map basePrices) { this.basePrices = basePrices; } public boolean isIllegalTradeEnabled() { return illegalTradeEnabled; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 3e1de5e..61642bf 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -27,6 +27,7 @@ import world.bentobox.tradewinds.commands.AdminReflagCommand; import world.bentobox.tradewinds.commands.AdminTpIslandCommand; import world.bentobox.tradewinds.commands.TWChartCommand; +import world.bentobox.tradewinds.commands.TWRestartCommand; import world.bentobox.tradewinds.commands.TWSpawnCommand; import world.bentobox.tradewinds.commands.TWStarChartCommand; import world.bentobox.tradewinds.commands.TWTradeCommand; @@ -124,6 +125,7 @@ public void setup() { new TWChartCommand(this); new TWStarChartCommand(this); new TWTradeCommand(this); + new TWRestartCommand(this); new IslandInfoCommand(this); new IslandSettingsCommand(this); new IslandLanguageCommand(this); @@ -174,6 +176,17 @@ public void onEnable() { Flags.CREEPER_DAMAGE.setDefaultSetting(netherWorld, false); Flags.CREEPER_GRIEFING.setDefaultSetting(netherWorld, true); } + // The open ocean and wild islets are free country: every protection + // flag defaults to allowed OUTSIDE island protection ranges. Trading + // islands keep their own band flags; player islands (Stage 7) theirs. + getPlugin().getFlagsManager().getFlags().stream() + .filter(flag -> flag.getType() == world.bentobox.bentobox.api.flags.Flag.Type.PROTECTION) + .forEach(flag -> { + flag.setDefaultSetting(islandWorld, true); + if (netherWorld != null) { + flag.setDefaultSetting(netherWorld, true); + } + }); // Register trading islands lazily as their center chunks first load registerListener(new GalaxyIslandRegistrar(this)); // Seal both worlds against portals - the interstice is warp-failure-only @@ -387,7 +400,7 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { galaxyEngine = new GalaxyEngine(new GalaxyConfig(seed, s.getGalaxyMinSeparation(), s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight(), typeWeights(), - s.getSpawnIsletRadius())); + s.getSpawnIsletRadius(), s.getWildIsletChance(), s.getWildIsletRadius())); log("TradeWinds galaxy seed: " + seed); } return galaxyEngine; diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWRestartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWRestartCommand.java new file mode 100644 index 0000000..2ff694d --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWRestartCommand.java @@ -0,0 +1,58 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; + +import world.bentobox.bentobox.api.commands.ConfirmableCommand; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; + +/** + * The destitute's mercy: restart the trading career - balance back to the + * starting amount, a fresh starter kit at spawn, chart kept (knowledge is not + * wealth). Limited uses so it cannot be farmed. + * + * @author tastybento + */ +public class TWRestartCommand extends ConfirmableCommand { + + public TWRestartCommand(CompositeCommand parent) { + super(parent, "restart"); + } + + @Override + public void setup() { + setPermission("island.restart"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.restart.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + TWPlayerData data = addon.getPlayerDataManager().get(user.getUniqueId()); + int max = addon.getSettings().getMaxRestarts(); + if (max >= 0 && data.getRestartsUsed() >= max) { + user.sendMessage("tradewinds.restart.none-left", TextVariables.NUMBER, String.valueOf(max)); + return false; + } + askConfirmation(user, user.getTranslation("tradewinds.restart.confirm"), () -> restart(addon, user, data)); + return true; + } + + private void restart(TradeWinds addon, User user, TWPlayerData data) { + data.setRestartsUsed(data.getRestartsUsed() + 1); + // Zero the balance; the starter kit deposit brings it back to starting + addon.getPlugin().getVault().ifPresent(vault -> vault.withdraw(user, vault.getBalance(user))); + data.setStarterKitGiven(false); + addon.getPlayerDataManager().save(user.getUniqueId()); + // Back to the spawn islet with a fresh kit + user.getPlayer().teleport(addon.getOverWorld().getSpawnLocation()); + addon.getStarterKit().onSpawnArrival(user.getPlayer()); + int max = addon.getSettings().getMaxRestarts(); + user.sendMessage("tradewinds.restart.done", TextVariables.NUMBER, + max < 0 ? "unlimited" : String.valueOf(max - data.getRestartsUsed())); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index a1bc54e..43224fa 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -40,6 +40,12 @@ public class TWPlayerData implements DataObject { @Expose private int expandersPurchased; + /** + * Career restarts consumed (/tw restart). + */ + @Expose + private int restartsUsed; + public TWPlayerData() { // Required by the database } @@ -88,6 +94,14 @@ public void setExpandersPurchased(int expandersPurchased) { this.expandersPurchased = expandersPurchased; } + public int getRestartsUsed() { + return restartsUsed; + } + + public void setRestartsUsed(int restartsUsed) { + this.restartsUsed = restartsUsed; + } + public boolean isStarterKitGiven() { return starterKitGiven; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 8ef8ea5..104ed51 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -30,6 +30,8 @@ public class MarketService { /** PDC key marking cargo expander items. */ public static final NamespacedKey EXPANDER_KEY = NamespacedKey.fromString("tradewinds:expander"); + /** PDC key marking customs-stamped (trader-bought) goods - the API marker. */ + public static final NamespacedKey STAMP_KEY = NamespacedKey.fromString("tradewinds:stamp"); private final TradeWinds addon; private final PriceEngine priceEngine; @@ -47,19 +49,71 @@ public PriceEngine getPriceEngine() { } /** - * What this island offers for sale: its type's catalog, with a fuel - * guarantee - if the catalog carries no warp fuel, CHARCOAL is added, so - * anyone with money can always buy their way off rowing. Only the skint - * and fuel-less row. + * What this island trades: its type's produce catalog. */ public java.util.List saleCatalog(IslandSpec spec) { - java.util.List catalog = new java.util.ArrayList<>(TypeEconomy.catalog(spec.type())); - boolean hasFuel = catalog.stream() + return TypeEconomy.catalog(spec.type()); + } + + /** + * The outfitter's shelf: survival essentials every island guarantees. + * Bread always; fuel (CHARCOAL) unless the trade catalog already sells + * fuel - only the skint AND fuel-less row; plus per-type gear (smiths at + * INDUSTRIAL, beds at AGRICULTURAL, rods at FISHING). + */ + public java.util.List outfitterCatalog(IslandSpec spec) { + java.util.List shelf = new java.util.ArrayList<>(); + shelf.add(Material.BREAD); + boolean tradeHasFuel = saleCatalog(spec).stream() .anyMatch(m -> addon.getSettings().getFuelValues().getOrDefault(m.name(), 0.0) > 0); - if (!hasFuel) { - catalog.add(Material.CHARCOAL); + if (!tradeHasFuel) { + shelf.add(Material.CHARCOAL); + } + shelf.addAll(TypeEconomy.outfitterExtras(spec.type())); + return shelf; + } + + /** + * Apply the customs stamp: trader-bought goods carry a PDC marker and a + * lore line. Only stamped goods can be sold back to traders - homegrown + * and homemade items are for living with, not for selling (with the + * illegal exceptions the customs office would rather not discuss). + */ + public ItemStack stamp(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return item; } - return catalog; + meta.getPersistentDataContainer().set(STAMP_KEY, PersistentDataType.STRING, "stamped"); + java.util.List lore = meta.lore() == null ? new java.util.ArrayList<>() + : new java.util.ArrayList<>(meta.lore()); + lore.add(Component.text("\u2693 Customs Stamped", NamedTextColor.DARK_AQUA)); + meta.lore(lore); + if (addon.getSettings().isStampGlint()) { + meta.setEnchantmentGlintOverride(true); + } + item.setItemMeta(meta); + return item; + } + + /** + * Is this item customs-stamped (trader-bought)? Public API for other + * addons. + */ + public boolean isStamped(ItemStack item) { + return item != null && item.hasItemMeta() + && item.getItemMeta().getPersistentDataContainer().has(STAMP_KEY, PersistentDataType.STRING); + } + + /** + * The filter deciding what traders will buy: stamped goods, plus the + * configured unstamped exceptions (contraband - if the illegal trade is + * enabled at all). + */ + public java.util.function.Predicate sellableFilter() { + return stack -> isStamped(stack) + || (addon.getSettings().isIllegalTradeEnabled() + && addon.getSettings().getUnstampedSellables().contains(stack.getType().name())); } /** @@ -113,8 +167,9 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { if (unitPrice.isEmpty() || vault.isEmpty() || amount <= 0) { return 0; } - int count = Math.min(addon.getHoldService().count(player, material), amount); + int count = Math.min(addon.getHoldService().count(player, material, sellableFilter()), amount); if (count <= 0) { + User.getInstance(player).sendMessage("tradewinds.trade.not-stamped"); return 0; } TWTradeEvent event = new TWTradeEvent(player, spec, material, count, unitPrice.get(), true); @@ -122,7 +177,7 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { if (event.isCancelled()) { return 0; } - int removed = addon.getHoldService().remove(player, material, count); + int removed = addon.getHoldService().remove(player, material, count, sellableFilter()); double total = PriceModel.round2(removed * unitPrice.get()); vault.get().deposit(User.getInstance(player), total); addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), removed); @@ -156,8 +211,9 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { if (event.isCancelled()) { return 0; } - // Limit by hold space: add first, pay for what fit - int added = addon.getHoldService().add(player, new ItemStack(material, affordable)); + // Limit by hold space: add first, pay for what fit. Bought goods are + // customs stamped - the only goods traders will buy back. + int added = addon.getHoldService().add(player, stamp(new ItemStack(material, affordable))); if (added <= 0) { user.sendMessage("tradewinds.trade.no-hold-space"); return 0; diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 001d3ef..069ef33 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -54,9 +54,11 @@ public void openMain(Player player, IslandSpec spec) { () -> openSell(player, spec))); } if (!addon.getMarketService().saleCatalog(spec).isEmpty()) { - buttons.add(button("Buy goods", NamedTextColor.AQUA, "Buy this island's goods into your hold", - () -> openBuy(player, spec))); + buttons.add(button("Buy goods", NamedTextColor.AQUA, "Buy this island's trade goods into your hold", + () -> openBuy(player, spec, addon.getMarketService().saleCatalog(spec), " - Buying"))); } + buttons.add(button("Outfitter", NamedTextColor.GREEN, "Food, fuel and gear for the sailing life", + () -> openBuy(player, spec, addon.getMarketService().outfitterCatalog(spec), " - Outfitter"))); int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); if (owned < addon.getSettings().getExpanderCap()) { double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); @@ -110,19 +112,19 @@ private void sellThenReopen(Player player, IslandSpec spec, Material material, i /** * The buy page: the island's catalog in batches. */ - public void openBuy(Player player, IslandSpec spec) { + public void openBuy(Player player, IslandSpec spec, List catalog, String titleSuffix) { List buttons = new ArrayList<>(); - for (Material material : addon.getMarketService().saleCatalog(spec)) { + for (Material material : catalog) { Optional price = addon.getMarketService().playerBuysAt(spec, material); price.ifPresent(unit -> { String name = MarketService.pretty(material); String each = String.format("$%.2f each; limited by balance and hold space", unit); buttons.add(button(String.format("%s x1 - $%.2f", name, unit), NamedTextColor.AQUA, each, - () -> buyThenReopen(player, spec, material, 1))); + () -> buyThenReopen(player, spec, material, 1, catalog, titleSuffix))); buttons.add(button(String.format("x%d - $%.2f", MID_BATCH, unit * MID_BATCH), NamedTextColor.AQUA, - each, () -> buyThenReopen(player, spec, material, MID_BATCH))); + each, () -> buyThenReopen(player, spec, material, MID_BATCH, catalog, titleSuffix))); buttons.add(button(String.format("x%d - $%.2f", BIG_BATCH, unit * BIG_BATCH), NamedTextColor.AQUA, - each, () -> buyThenReopen(player, spec, material, BIG_BATCH))); + each, () -> buyThenReopen(player, spec, material, BIG_BATCH, catalog, titleSuffix))); }); } if (buttons.isEmpty()) { @@ -130,13 +132,15 @@ public void openBuy(Player player, IslandSpec spec) { openMain(player, spec); return; } - show(player, spec.name() + " - Buying", - List.of("Goods sold into your hold", statusLine(player)), buttons, backButton(player, spec), 3); + show(player, spec.name() + titleSuffix, + List.of("Goods sold into your hold - customs stamped", statusLine(player)), buttons, + backButton(player, spec), 3); } - private void buyThenReopen(Player player, IslandSpec spec, Material material, int amount) { + private void buyThenReopen(Player player, IslandSpec spec, Material material, int amount, List catalog, + String titleSuffix) { addon.getMarketService().buy(player, spec, material, amount); - openBuy(player, spec); + openBuy(player, spec, catalog, titleSuffix); } /** @@ -148,7 +152,9 @@ record SellOffer(Material material, int amount, double unitPrice, double total) List sellOffers(Player player, IslandSpec spec) { List offers = new ArrayList<>(); - for (Map.Entry entry : addon.getHoldService().contents(player).entrySet()) { + // Traders only buy customs-stamped goods (plus the illegal exceptions) + for (Map.Entry entry : addon.getHoldService() + .contents(player, addon.getMarketService().sellableFilter()).entrySet()) { addon.getMarketService().playerSellsAt(spec, entry.getKey()) .ifPresent(unit -> offers.add(new SellOffer(entry.getKey(), entry.getValue(), unit, PriceModel.round2(unit * entry.getValue())))); diff --git a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java index df4fc58..bd2e92c 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java @@ -54,10 +54,31 @@ public final class TypeEconomy { TradeCategory.LUXURY, List.of(Material.CAKE, Material.GOLDEN_APPLE), TradeCategory.MISC, List.of()); + /** + * Per-type outfitter stock beyond the universal bread/fuel: smiths arm + * you at INDUSTRIAL, farms sell beds, fisheries sell rods. + */ + 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), + IslandType.FISHING, List.of(Material.FISHING_ROD), + IslandType.FOREST, List.of(Material.WHITE_BED), + IslandType.MINING, List.of(Material.IRON_PICKAXE), + IslandType.LUXURY, List.of(Material.GOLDEN_APPLE), + IslandType.FROZEN, List.of()); + private TypeEconomy() { // Static use only } + /** + * The outfitter's per-type stock (beyond universal essentials). + */ + public static List outfitterExtras(IslandType type) { + return OUTFITTER_EXTRAS.getOrDefault(type, List.of()); + } + public static Set produces(IslandType type) { return PRODUCES.get(type); } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java index c43ebf3..d2c5bda 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -22,15 +22,20 @@ * @param typeWeights relative spawn weight per island type; a zero or missing * total falls back to the built-in defaults * @param spawnIsletRadius radius of the safe spawn islet at the origin (0 = none) + * @param wildIsletChance chance (0-1) that an empty cell hosts a wild islet + * @param wildIsletRadius terrain radius of wild islets (0 = none) * * @author tastybento */ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, - int spawnIsletRadius) { + int spawnIsletRadius, double wildIsletChance, int wildIsletRadius) { /** Default spawn islet radius in blocks. */ public static final int DEFAULT_SPAWN_ISLET_RADIUS = 48; + /** Default wild islet chance / radius. */ + public static final double DEFAULT_WILD_CHANCE = 0.3; + public static final int DEFAULT_WILD_RADIUS = 70; public GalaxyConfig { if (typeWeights == null || typeWeights.values().stream().mapToInt(w -> Math.max(0, w)).sum() <= 0) { @@ -38,13 +43,23 @@ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int } } + /** + * Convenience constructor with default islet parameters. + */ + public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, + int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, + int spawnIsletRadius) { + this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, + typeWeights, spawnIsletRadius, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); + } + /** * Convenience constructor using the default spawn islet radius. */ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, - typeWeights, DEFAULT_SPAWN_ISLET_RADIUS); + typeWeights, DEFAULT_SPAWN_ISLET_RADIUS, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); } /** @@ -53,7 +68,7 @@ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLif public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, - defaultTypeWeights(), DEFAULT_SPAWN_ISLET_RADIUS); + defaultTypeWeights(), DEFAULT_SPAWN_ISLET_RADIUS, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index 682a67d..6d357d0 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -40,6 +40,15 @@ public class GalaxyEngine { private static final long SALT_BIOME = 0xB10E0001L; private static final long SALT_NAME = 0x9A3E0001L; private static final long SALT_DOCK = 0xD0C4B0A7L; + private static final long SALT_WILD = 0x317DBEA7L; + private static final long SALT_WILD_X = 0x317DBEA8L; + private static final long SALT_WILD_Z = 0x317DBEA9L; + private static final long SALT_WILD_BIOME = 0x317DBEAAL; + + /** Vanilla biomes wild islets draw from - Minecraft-stuff land. */ + private static final List WILD_BIOMES = List.of("minecraft:plains", "minecraft:forest", + "minecraft:birch_forest", "minecraft:jungle", "minecraft:savanna", "minecraft:swamp", + "minecraft:flower_forest", "minecraft:dark_forest"); // Dock/plaza geometry, as fractions of the terrain radius. The plaza sits // at ~45% out (where natural terrain is already near sea level, so the @@ -233,9 +242,79 @@ public int landLiftAt(int blockX, int blockZ) { best = Math.max(best, 0.5 * (1 + Math.cos(Math.PI * d / isletRadius))); } } + // Wild islets: free land between the trading islands + Optional wild = wildIsletAt(blockX, blockZ); + if (wild.isPresent()) { + double d = Math.hypot((double) blockX - wild.get()[0], (double) blockZ - wild.get()[1]); + best = Math.max(best, 0.5 * (1 + Math.cos(Math.PI * d / config.wildIsletRadius()))); + } return (int) Math.round(config.landLift() * best); } + /** + * The wild islet hosted by a cell, if any: cells without a trading island + * may roll a small unnamed island - free land for mining, farming and + * building ("Minecraft stuff"), and claim material for later stages. + * + * @param cellX cell x + * @param cellZ cell z + * @return {centerX, centerZ} or empty + */ + public Optional wildIsletInCell(int cellX, int cellZ) { + if (config.wildIsletChance() <= 0 || islandInCell(cellX, cellZ).isPresent()) { + return Optional.empty(); + } + if (Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD)) >= config.wildIsletChance()) { + return Optional.empty(); + } + int size = config.cellSize(); + int jitter = config.jitter(); + double jx = Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_X)) * 2 - 1; + double jz = Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_Z)) * 2 - 1; + return Optional.of(new int[] { (int) Math.round((cellX + 0.5) * size + jx * jitter), + (int) Math.round((cellZ + 0.5) * size + jz * jitter) }); + } + + /** + * The wild islet whose footprint covers a column, if any. + * + * @param blockX block x + * @param blockZ block z + * @return {centerX, centerZ} or empty + */ + public Optional wildIsletAt(int blockX, int blockZ) { + int radius = config.wildIsletRadius(); + if (radius <= 0) { + return Optional.empty(); + } + int size = config.cellSize(); + int cellX = Math.floorDiv(blockX, size); + int cellZ = Math.floorDiv(blockZ, size); + for (int cx = cellX - 1; cx <= cellX + 1; cx++) { + for (int cz = cellZ - 1; cz <= cellZ + 1; cz++) { + Optional islet = wildIsletInCell(cx, cz); + if (islet.isPresent()) { + long dx = (long) blockX - islet.get()[0]; + long dz = (long) blockZ - islet.get()[1]; + if (dx * dx + dz * dz <= (long) radius * radius) { + return islet; + } + } + } + } + return Optional.empty(); + } + + /** + * A wild islet's whole-island biome (seeded from its cell). + */ + public String wildIsletBiome(int centerX, int centerZ) { + int size = config.cellSize(); + long hash = Hashing.cellHash(config.seed(), Math.floorDiv(centerX, size), Math.floorDiv(centerZ, size), + SALT_WILD_BIOME); + return WILD_BIOMES.get((int) Math.floorMod(hash, WILD_BIOMES.size())); + } + /** * The number of resident villagers an island supports (3-5, seeded). * Deterministic so the respawn audit knows what "fully staffed" means. @@ -317,6 +396,10 @@ public Optional biomeKeyAt(int blockX, int blockZ) { && Math.hypot(blockX, blockZ) < config.spawnIsletRadius()) { return Optional.of("minecraft:plains"); } + Optional wild = wildIsletAt(blockX, blockZ); + if (wild.isPresent()) { + return Optional.of(wildIsletBiome(wild.get()[0], wild.get()[1])); + } int radius = config.terrainRadius(); long r2 = (long) radius * radius; long ring2 = 4L * radius * radius; diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index 551269d..94194c2 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -4,6 +4,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Predicate; import org.bukkit.Material; import org.bukkit.block.ShulkerBox; @@ -40,8 +41,19 @@ public HoldService(TradeWinds addon) { * order. */ public Map contents(Player player) { + return contents(player, stack -> true); + } + + /** + * Hold contents matching a stack filter (e.g. customs-stamped only). + */ + public Map contents(Player player, Predicate filter) { Map result = new LinkedHashMap<>(); - forEachHoldStack(player, stack -> result.merge(stack.getType(), stack.getAmount(), Integer::sum)); + forEachHoldStack(player, stack -> { + if (filter.test(stack)) { + result.merge(stack.getType(), stack.getAmount(), Integer::sum); + } + }); return result; } @@ -52,6 +64,13 @@ public int count(Player player, Material material) { return contents(player).getOrDefault(material, 0); } + /** + * How many of a material, counting only stacks passing the filter. + */ + public int count(Player player, Material material, Predicate filter) { + return contents(player, filter).getOrDefault(material, 0); + } + /** * Approximate free capacity of the hold in items, assuming 64-stacks: * empty boat/expander slots count 64, partial stacks their headroom, @@ -95,6 +114,16 @@ private int freeIn(Inventory inventory) { * @return how many were actually removed */ public int remove(Player player, Material material, int amount) { + return remove(player, material, amount, stack -> true); + } + + /** + * Remove up to {@code amount} of a material, taking only stacks that pass + * the filter (e.g. customs-stamped only). + * + * @return how many were actually removed + */ + public int remove(Player player, Material material, int amount, Predicate filter) { int[] left = { amount }; if (player.getVehicle() instanceof ChestBoat boat) { Inventory inv = boat.getInventory(); @@ -105,7 +134,7 @@ public int remove(Player player, Material material, int amount) { if (stack == null) { continue; } - if (stack.getType() == material) { + if (stack.getType() == material && filter.test(stack)) { int take = Math.min(left[0], stack.getAmount()); stack.setAmount(stack.getAmount() - take); if (stack.getAmount() <= 0) { @@ -113,7 +142,7 @@ public int remove(Player player, Material material, int amount) { } left[0] -= take; } else if (isExpander(stack)) { - left[0] -= removeFromShulker(stack, material, left[0]); + left[0] -= removeFromShulker(stack, material, left[0], filter); } } } @@ -121,7 +150,7 @@ public int remove(Player player, Material material, int amount) { if (left[0] <= 0) { break; } - left[0] -= removeFromBundle(bundleItem, material, left[0]); + left[0] -= removeFromBundle(bundleItem, material, left[0], filter); } return amount - left[0]; } @@ -223,7 +252,7 @@ private List shulkerContents(ItemStack expander) { return List.of(); } - private int removeFromShulker(ItemStack expander, Material material, int amount) { + private int removeFromShulker(ItemStack expander, Material material, int amount, Predicate filter) { if (!(expander.getItemMeta() instanceof BlockStateMeta meta) || !(meta.getBlockState() instanceof ShulkerBox box)) { return 0; @@ -233,7 +262,7 @@ private int removeFromShulker(ItemStack expander, Material material, int amount) if (removed >= amount) { break; } - if (stack != null && stack.getType() == material) { + if (stack != null && stack.getType() == material && filter.test(stack)) { int take = Math.min(amount - removed, stack.getAmount()); stack.setAmount(stack.getAmount() - take); if (stack.getAmount() <= 0) { @@ -265,7 +294,7 @@ private int addToShulker(ItemStack expander, ItemStack items, int amount) { return added; } - private int removeFromBundle(ItemStack bundleItem, Material material, int amount) { + private int removeFromBundle(ItemStack bundleItem, Material material, int amount, Predicate filter) { if (!(bundleItem.getItemMeta() instanceof BundleMeta bundle)) { return 0; } @@ -273,7 +302,7 @@ private int removeFromBundle(ItemStack bundleItem, Material material, int amount List items = new ArrayList<>(bundle.getItems()); List kept = new ArrayList<>(); for (ItemStack stack : items) { - if (removed < amount && stack.getType() == material) { + if (removed < amount && stack.getType() == material && filter.test(stack)) { int take = Math.min(amount - removed, stack.getAmount()); removed += take; if (stack.getAmount() > take) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7be315f..06dc978 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -32,6 +32,11 @@ galaxy: # Radius of the safe spawn islet generated at the world origin - players spawn # and respawn there. 0 disables it (not recommended: seabed respawns kill). spawn-islet-radius: 48 + # Chance (0-1) that a galaxy cell WITHOUT a trading island hosts a wild islet - + # small unnamed islands, unprotected: mine, farm, build, live. + wild-islet-chance: 0.3 + # Terrain radius of wild islets. 0 disables them. + wild-islet-radius: 70 # Relative spawn weight per island type. Higher = more common; 0 disables a type. type-weights: AGRICULTURAL: 10 @@ -151,6 +156,11 @@ economy: expander-cap: 4 # Maximum trading bundles that count as hold space. max-bundles: 3 + # Give customs-stamped goods an enchantment glint as well as their lore line. + stamp-glint: false + # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. + unstamped-sellables: + - SUGAR # Base prices (Material -> price). Anything not listed is priced by deriving from # its crafting recipe (BlueBook logic, embedded); underivable = untradeable. base-prices: @@ -181,6 +191,9 @@ economy: CAKE: 20.0 GOLDEN_APPLE: 150.0 # (the full default table ships in code; add or override entries here) +player: + # Career restarts a destitute player may use (/tw restart). -1 unlimited, 0 none. + max-restarts: 3 illegal-trade: # Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling. # Set false for family-friendly servers - removes the entire crime layer cleanly. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 40e092a..cae0996 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -22,6 +22,8 @@ tradewinds: description: "receive a Star Chart map of your charted islands" trade: description: "open the island market (within its protection range)" + restart: + description: "restart your trading career (limited uses)" admin: help: description: "admin command for TradeWinds" @@ -51,8 +53,13 @@ tradewinds: empty: "&c Your chart is empty - row into an island's waters to chart it." header: "&b Your chart ([number] islands):" entry: "&e [name] &7- [type], [band], [distance] blocks" + restart: + confirm: "&c Restart your career? Balance and kit reset; your chart is kept." + none-left: "&c No restarts left (maximum [number])." + done: "&6 A fresh start. Restarts remaining: [number]." trade: not-at-market: "&c There is no market here - trade within an island's protection range." + not-stamped: "&c Traders only buy customs-stamped goods (or... certain other merchandise)." nothing-to-sell: "&c Nothing in your hold that this island will pay for." nothing-for-sale: "&c This island has nothing for sale - it only buys." sold: "&a Sold [amount] x [material] for $[price]." diff --git a/src/test/java/world/bentobox/tradewinds/economy/CustomsStampTest.java b/src/test/java/world/bentobox/tradewinds/economy/CustomsStampTest.java new file mode 100644 index 0000000..d4cc643 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/CustomsStampTest.java @@ -0,0 +1,74 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.Material; +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.TradeWinds; +import world.bentobox.tradewinds.dataobjects.IslandDataManager; + +/** + * The customs stamp: only trader-bought (stamped) goods are sellable - except + * the merchandise the customs office pretends not to know about. + * + * @author tastybento + */ +class CustomsStampTest extends CommonTestSetup { + + private TradeWinds addon; + private MarketService service; + private Settings settings; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + when(addon.getIslandDataManager()).thenReturn(mock(IslandDataManager.class)); + service = new MarketService(addon); + } + + private ItemStack stack(Material material, boolean stamped) { + ItemStack stack = mock(ItemStack.class); + when(stack.getType()).thenReturn(material); + when(stack.hasItemMeta()).thenReturn(true); + ItemMeta meta = mock(ItemMeta.class); + PersistentDataContainer pdc = mock(PersistentDataContainer.class); + when(pdc.has(MarketService.STAMP_KEY, PersistentDataType.STRING)).thenReturn(stamped); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(stack.getItemMeta()).thenReturn(meta); + return stack; + } + + @Test + void testOnlyStampedGoodsAreSellable() { + assertTrue(service.sellableFilter().test(stack(Material.WHEAT, true))); + // Homegrown wheat is for eating, not selling + assertFalse(service.sellableFilter().test(stack(Material.WHEAT, false))); + assertFalse(service.sellableFilter().test(stack(Material.DIAMOND, false))); + } + + @Test + void testContrabandExceptionSellsUnstamped() { + // Homegrown sugar is the smallholder economy - sellable unstamped + assertTrue(service.sellableFilter().test(stack(Material.SUGAR, false))); + // ...but only while the illegal trade exists at all (family servers) + settings.setIllegalTradeEnabled(false); + assertFalse(service.sellableFilter().test(stack(Material.SUGAR, false))); + // Stamped sugar (bought legitimately) is always fine + assertTrue(service.sellableFilter().test(stack(Material.SUGAR, true))); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java index 339c78b..b75450c 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java @@ -47,25 +47,41 @@ private IslandSpec spec(IslandType type) { } @Test - void testEveryIslandTypeSellsFuel() { + void testEveryIslandSellsFuelAndFood() { for (IslandType type : IslandType.values()) { - List catalog = service.saleCatalog(spec(type)); - assertTrue(catalog.stream() + List shelf = new java.util.ArrayList<>(service.saleCatalog(spec(type))); + shelf.addAll(service.outfitterCatalog(spec(type))); + assertTrue(shelf.stream() .anyMatch(m -> settings.getFuelValues().getOrDefault(m.name(), 0.0) > 0), type + " must sell some kind of fuel"); - assertTrue(!catalog.isEmpty(), type + " catalog must never be empty"); + assertTrue(shelf.contains(Material.BREAD) || shelf.contains(Material.COOKED_BEEF) + || shelf.contains(Material.COOKED_COD), type + " must sell food"); } } @Test void testCharcoalIsTheFallbackNotTheRule() { - // FOREST sells logs (fuel) already - no charcoal forced in - assertTrue(!service.saleCatalog(spec(IslandType.FOREST)).contains(Material.CHARCOAL)); - // MINING sells coal - no charcoal forced in - assertTrue(!service.saleCatalog(spec(IslandType.MINING)).contains(Material.CHARCOAL)); - // LUXURY produces nothing: charcoal appears as the guaranteed fuel - assertTrue(service.saleCatalog(spec(IslandType.LUXURY)).contains(Material.CHARCOAL)); - // AGRICULTURAL produce (crops/food) has no fuel: charcoal appears - assertTrue(service.saleCatalog(spec(IslandType.AGRICULTURAL)).contains(Material.CHARCOAL)); + // FOREST trades logs (fuel) already - no charcoal on the shelf + assertTrue(!service.outfitterCatalog(spec(IslandType.FOREST)).contains(Material.CHARCOAL)); + // MINING trades coal - no charcoal + assertTrue(!service.outfitterCatalog(spec(IslandType.MINING)).contains(Material.CHARCOAL)); + // LUXURY trades nothing burnable: charcoal appears on the outfitter shelf + assertTrue(service.outfitterCatalog(spec(IslandType.LUXURY)).contains(Material.CHARCOAL)); + assertTrue(service.outfitterCatalog(spec(IslandType.AGRICULTURAL)).contains(Material.CHARCOAL)); + } + + @Test + void testOutfitterFitsTheDialog() { + for (IslandType type : IslandType.values()) { + assertTrue(service.outfitterCatalog(spec(type)).size() <= 8, + type + " outfitter must fit the dialog without scrolling"); + } + } + + @Test + void testSmithsArmYou() { + List smith = service.outfitterCatalog(spec(IslandType.INDUSTRIAL)); + assertTrue(smith.contains(Material.IRON_SWORD)); + assertTrue(smith.contains(Material.IRON_CHESTPLATE)); } } diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index 8b1f18f..dd7f83b 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -228,6 +228,40 @@ void testSpawnIslet() { assertEquals(0, off.landLiftAt(0, 0)); } + @Test + void testWildIslets() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + // With density 0 almost every cell is empty: some roll wild islets + int found = 0; + int[] sample = null; + for (int cx = -10; cx <= 10; cx++) { + for (int cz = -10; cz <= 10; cz++) { + if (engine.islandInCell(cx, cz).isEmpty() && engine.wildIsletInCell(cx, cz).isPresent()) { + found++; + sample = engine.wildIsletInCell(cx, cz).get(); + } + } + } + // ~30% of 441 cells + assertTrue(found > 60 && found < 200, "Wild islet count off: " + found); + // Deterministic across engines + GalaxyEngine again = new GalaxyEngine(config(SEED, 0.0)); + assertTrue(again.wildIsletAt(sample[0], sample[1]).isPresent()); + // Terrain rises there, with a vanilla wild biome + assertEquals(45, engine.landLiftAt(sample[0], sample[1])); + assertTrue(engine.biomeKeyAt(sample[0], sample[1]).orElseThrow().startsWith("minecraft:")); + // Never inside a trading island's cell + GalaxyEngine dense = new GalaxyEngine(config(SEED, 1.0)); + for (int cx = -5; cx <= 5; cx++) { + for (int cz = -5; cz <= 5; cz++) { + if (dense.islandInCell(cx, cz).isPresent()) { + assertTrue(dense.wildIsletInCell(cx, cz).isEmpty(), + "Cell with a trading island must not also host a wild islet"); + } + } + } + } + @Test void testNamesAreDistinctEnough() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); From 9b91f249fcf5f67871b5d4c0eca0ebafa12c33ec Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:15:18 -0700 Subject: [PATCH 021/112] The Shipwright: buy boats and chest boats at every market Shipwright page gathers the cargo progression: Oak Boat ($20) and Oak Chest Boat ($60, explicit base prices above plank cost - crafting from wild-islet timber stays the frugal path) delivered to the player inventory, plus the cargo-expander ladder moved in from the main menu. 127 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 10 +++++ docs/PROGRESS.md | 13 ++++++ .../world/bentobox/tradewinds/Settings.java | 4 ++ .../tradewinds/economy/MarketService.java | 41 +++++++++++++++++ .../tradewinds/economy/TradeDialog.java | 45 ++++++++++++++----- src/main/resources/config.yml | 2 + .../tradewinds/economy/SaleCatalogTest.java | 10 +++++ 7 files changed, 114 insertions(+), 11 deletions(-) diff --git a/TESTING.md b/TESTING.md index b94320c..2d0f338 100644 --- a/TESTING.md +++ b/TESTING.md @@ -317,3 +317,13 @@ have not visited). The protection flip and stamps work immediately. (protection flipped outside named islands). Trading islands remain protected. - [ ] **/tw restart**: confirmation, then balance resets to starting, fresh kit at spawn, chart KEPT; counter decrements (3 by default); refuses at 0. + +### Stage 4h — the Shipwright (boat acquisition) +- [ ] Every market's main menu has a "Shipwright" page: Oak Boat ($20-ish), Oak Chest + Boat ($60-ish), and the Cargo Expander ladder (moved here from the main menu). +- [ ] Buying a hull delivers the (stamped) boat item to your INVENTORY — place it at + the dock and ride off. Expanders still require a chest boat (hold delivery). +- [ ] Boats are NOT valid warp fuel (they are hulls, not firewood). +- [ ] Wild islets have trees: chop, plank, craft boat + chest → chest boat, the frugal + path. Bought hulls cost more than crafted ones by design. +- [ ] Boatless + broke: wild-islet timber or /tw restart are the exits. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 96ace64..5d20448 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,19 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The Shipwright (2026-07-30) + +Boat acquisition (Ben's design): every market's Shipwright page sells Oak +Boat ($20) and Oak Chest Boat ($60) - explicit base prices above raw plank +cost (shipwright labor) so crafting from wild-islet timber stays the frugal +path - plus the cargo-expander ladder (moved from the main menu; the whole +cargo progression boat -> chest boat -> expanders now lives in one shop). +Hulls deliver to the player INVENTORY (a shipless sailor has no hold), via +MarketService.buyToInventory, stamped like any purchase. No free boat on +death: buy, craft, or /tw restart. Pitfall: first put the boat prices into +defaultFuelValues by matching the wrong map's tail - boats briefly counted +as 20 fuel units. 127 tests green. + ## The two economies: stamps, outfitters, wild islets, restart (2026-07-30) Ben's survival-design discussion (players died gearless and hungry; farming diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 8a1916d..3bca8ed 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -374,6 +374,10 @@ private static Map defaultBasePrices() { map.put("QUARTZ", 8.0); map.put("LAPIS_LAZULI", 6.0); map.put("REDSTONE", 3.0); map.put("LEATHER", 4.0); map.put("WHITE_WOOL", 2.0); map.put("STRING", 1.5); map.put("BEEF", 2.5); map.put("PORKCHOP", 2.5); map.put("CHICKEN", 2.0); map.put("MUTTON", 2.0); + // Hulls: priced above raw plank cost - shipwright labor. Frugal + // players craft their own from wild-islet timber. + map.put("OAK_BOAT", 20.0); + map.put("OAK_CHEST_BOAT", 60.0); return map; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 104ed51..f1cc282 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -73,6 +73,47 @@ public java.util.List outfitterCatalog(IslandSpec spec) { return shelf; } + /** + * The shipwright's slipway: hulls, bought straight to the player's + * inventory (a shipless sailor has no hold to receive into - a vessel is + * not cargo). Cargo expanders complete the shipwright's stock via + * {@link #buyExpander}. + */ + public java.util.List shipwrightCatalog() { + return java.util.List.of(Material.OAK_BOAT, Material.OAK_CHEST_BOAT); + } + + /** + * Buy an item delivered to the player's inventory rather than the hold - + * used for hulls only. Stamped like any purchase. + * + * @return true if bought + */ + public boolean buyToInventory(Player player, IslandSpec spec, Material material) { + Optional unitPrice = playerBuysAt(spec, material); + Optional vault = addon.getPlugin().getVault(); + if (unitPrice.isEmpty() || vault.isEmpty()) { + return false; + } + User user = User.getInstance(player); + if (!vault.get().has(user, unitPrice.get())) { + user.sendMessage("tradewinds.trade.cannot-afford"); + return false; + } + TWTradeEvent event = new TWTradeEvent(player, spec, material, 1, unitPrice.get(), false); + org.bukkit.Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return false; + } + vault.get().withdraw(user, unitPrice.get()); + ItemStack item = stamp(new ItemStack(material)); + player.getInventory().addItem(item).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + user.sendMessage("tradewinds.trade.bought", "[amount]", "1", "[material]", pretty(material), "[price]", + String.format("%.2f", unitPrice.get())); + return true; + } + /** * Apply the customs stamp: trader-bought goods carry a PDC marker and a * lore line. Only stamped goods can be sold back to traders - homegrown diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 069ef33..c9aeed2 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -59,17 +59,8 @@ public void openMain(Player player, IslandSpec spec) { } buttons.add(button("Outfitter", NamedTextColor.GREEN, "Food, fuel and gear for the sailing life", () -> openBuy(player, spec, addon.getMarketService().outfitterCatalog(spec), " - Outfitter"))); - int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); - if (owned < addon.getSettings().getExpanderCap()) { - double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); - buttons.add(button(String.format("Shipwright: Cargo Expander - $%.0f", price), NamedTextColor.GOLD, - "Shulker hold expansion (" + owned + "/" + addon.getSettings().getExpanderCap() - + " owned). Price doubles each time.", - () -> { - addon.getMarketService().buyExpander(player); - openMain(player, spec); - })); - } + buttons.add(button("Shipwright", NamedTextColor.GOLD, "Boats, chest boats and cargo expanders", + () -> openShipwright(player, spec))); show(player, spec.name() + " Market", List.of(spec.type().name() + ", " + spec.band().getDisplayName(), statusLine(player)), buttons, closeButton(), 1); @@ -143,6 +134,38 @@ private void buyThenReopen(Player player, IslandSpec spec, Material material, in openBuy(player, spec, catalog, titleSuffix); } + /** + * The shipwright's slipway: hulls to your inventory, expanders to your + * hold. The cargo progression lives here: boat -> chest boat -> expanders. + */ + public void openShipwright(Player player, IslandSpec spec) { + List buttons = new ArrayList<>(); + for (Material hull : addon.getMarketService().shipwrightCatalog()) { + addon.getMarketService().playerBuysAt(spec, hull).ifPresent(unit -> buttons.add(button( + String.format("%s - $%.2f", MarketService.pretty(hull), unit), NamedTextColor.GOLD, + "Delivered to your inventory - place it at the dock", + () -> { + addon.getMarketService().buyToInventory(player, spec, hull); + openShipwright(player, spec); + }))); + } + int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); + if (owned < addon.getSettings().getExpanderCap()) { + double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); + buttons.add(button(String.format("Cargo Expander - $%.0f", price), NamedTextColor.GOLD, + "Shulker hold expansion (" + owned + "/" + addon.getSettings().getExpanderCap() + + " owned). Price doubles each time. Stowed into your chest boat.", + () -> { + addon.getMarketService().buyExpander(player); + openShipwright(player, spec); + })); + } + show(player, spec.name() + " - Shipwright", + List.of("Hulls and holds. Smart sailors craft their own boats from wild timber.", + statusLine(player)), + buttons, backButton(player, spec), 1); + } + /** * One sellable line: what the hold has and what this island pays. Package * visible for tests. diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 06dc978..a40dfd9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -190,6 +190,8 @@ economy: AMETHYST_SHARD: 10.0 CAKE: 20.0 GOLDEN_APPLE: 150.0 + OAK_BOAT: 20.0 + OAK_CHEST_BOAT: 60.0 # (the full default table ships in code; add or override entries here) player: # Career restarts a destitute player may use (/tw restart). -1 unlimited, 0 none. diff --git a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java index b75450c..bd36e11 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java @@ -78,6 +78,16 @@ void testOutfitterFitsTheDialog() { } } + @Test + void testShipwrightSellsHulls() { + // Boats purchasable everywhere - lose your boat, buy your way afloat + assertTrue(service.shipwrightCatalog().contains(Material.OAK_BOAT)); + assertTrue(service.shipwrightCatalog().contains(Material.OAK_CHEST_BOAT)); + // Priced (explicit base prices - above raw plank cost) + assertTrue(service.basePrice(Material.OAK_BOAT).orElse(0.0) >= 20.0); + assertTrue(service.basePrice(Material.OAK_CHEST_BOAT).orElse(0.0) > service.basePrice(Material.OAK_BOAT).orElse(0.0)); + } + @Test void testSmithsArmYou() { List smith = service.outfitterCatalog(spec(IslandType.INDUSTRIAL)); From 1b7e38fb84bcf80d1f1320e4dd3f24283933e665 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:27:23 -0700 Subject: [PATCH 022/112] Declare default permissions for all commands in addon.yml Undeclared permissions default to op-only in Bukkit - non-op players were locked out of every command added since Stage 0 (warp, chart, starchart, trade, restart, settings). All player commands now default true; admin commands op. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 5 +++++ src/main/resources/addon.yml | 29 ++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/TESTING.md b/TESTING.md index 2d0f338..0215546 100644 --- a/TESTING.md +++ b/TESTING.md @@ -327,3 +327,8 @@ have not visited). The protection flip and stamps work immediately. - [ ] Wild islets have trees: chop, plank, craft boat + chest → chest boat, the frugal path. Bought hulls cost more than crafted ones by design. - [ ] Boatless + broke: wild-islet timber or /tw restart are the exits. + +### Stage 4i — command permissions +- [ ] As a NON-OP player (test account, no LuckPerms grants): /tw, spawn, warp, chart, + starchart, trade, restart, info, settings, language all work. +- [ ] Non-op cannot use /twadmin or its subcommands. diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 2b6d557..81ee823 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -13,17 +13,44 @@ permissions: description: Allow use of the TradeWinds player command default: true tradewinds.island.spawn: - description: Allow teleporting to the TradeWinds spawn + description: Allow teleporting to the ocean spawn + default: true + tradewinds.island.warp: + description: Allow opening the warp dialog at island borders + default: true + tradewinds.island.chart: + description: Allow viewing the chart (holograms afloat, list ashore) + default: true + tradewinds.island.starchart: + description: Allow receiving the Star Chart map + default: true + tradewinds.island.trade: + description: Allow trading at island markets + default: true + tradewinds.island.restart: + description: Allow restarting the trading career (config-capped) default: true tradewinds.island.info: description: Allow use of the island info command default: true + tradewinds.island.settings: + description: Allow viewing island settings (editing is rank-gated) + default: true tradewinds.island.language: description: Allow use of the language command default: true tradewinds.admin: description: Allow use of the TradeWinds admin command default: op + tradewinds.admin.islands: + description: Allow listing the nearest trading islands + default: op + tradewinds.admin.tpisland: + description: Allow teleporting to trading islands + default: op + tradewinds.admin.reflag: + description: Allow re-applying security-band flags to all islands + default: op tradewinds.admin.*: description: All TradeWinds admin permissions default: op From bb83691ce8bbc575f4ded4c8da8c191b7225a5f1 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:33:15 -0700 Subject: [PATCH 023/112] Protected spawn island using Stranger Realms geometry overrides isFixIslandCenter/isEnforceEqualRanges -> false (arbitrary centers and ranges - also protects our jittered trading islands from grid realignment and clears the path for Stage 7 claims). The spawn islet becomes a real unowned BentoBox spawn island: protection 100 (config), range 200 (provably overlap-free with starter islands), named Spawn, set as world spawn island. 128 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 11 +++++ docs/PROGRESS.md | 17 +++++++ .../world/bentobox/tradewinds/Settings.java | 6 +++ .../world/bentobox/tradewinds/TradeWinds.java | 47 +++++++++++++++++++ src/main/resources/config.yml | 2 + .../bentobox/tradewinds/TradeWindsTest.java | 10 ++++ 6 files changed, 93 insertions(+) diff --git a/TESTING.md b/TESTING.md index 0215546..b2dc77a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -332,3 +332,14 @@ have not visited). The protection flip and stamps work immediately. - [ ] As a NON-OP player (test account, no LuckPerms grants): /tw, spawn, warp, chart, starchart, trade, restart, info, settings, language all work. - [ ] Non-op cannot use /twadmin or its subcommands. + +### Stage 4j — protected spawn island +- [ ] Console logs "Registered the spawn island (protection 100)" on first enable. +- [ ] As NON-OP: breaking/placing blocks on the spawn islet is blocked; wild islets + and open ocean remain free-build. +- [ ] /twadmin info at spawn shows an unowned island named "Spawn", protection 100, + range 200 (small ranges are legal now — Stranger Realms overrides). +- [ ] Restart: BentoBox loads cleanly (no "island distance mismatch" panic), spawn + island persists as spawn. +- [ ] Trading islands still register/announce correctly (arbitrary centers untouched + by the isFixIslandCenter override). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5d20448..5b6e342 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,23 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Protected spawn island (2026-07-30) + +The protection flip left the spawn islet explicitly free-build (non-ops +could grief it). Fixed with Ben's own Stranger Realms machinery: TradeWinds +overrides isFixIslandCenter=false (arbitrary island centers - we had been +relying on luck that core's grid realignment never moved our jittered +trading islands) and isEnforceEqualRanges=false (arbitrary ranges - also +what Stage 7 claims will need). Then the spawn islet is a REAL BentoBox +spawn island: created unowned at the origin, protection +galaxy.spawn-protection-range (100), range shrunk to 200 (provably can +never overlap a starter island: min starter center is ~1250/axis > 200 + +1000). setSpawn + named "Spawn". Known wrinkle: the grid indexes the +creation-time range (1000) until first restart - on pathological seeds a +starter island rowed to in the very first session could fail to register +until restart heals the grid. hasDistanceMismatch honors the override, so +loads are safe. 128 tests green. + ## The Shipwright (2026-07-30) Boat acquisition (Ben's design): every market's Shipwright page sells Oak diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 3bca8ed..7f80df4 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -109,6 +109,10 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.wild-islet-radius", needsReset = true) private int wildIsletRadius = 70; + @ConfigComment("Protection radius of the spawn island (covers the islet plus its shore).") + @ConfigEntry(path = "galaxy.spawn-protection-range") + private int spawnProtectionRange = 100; + @ConfigComment("Relative spawn weight per island type. Higher = more common; 0 disables a type.") @ConfigComment("Types: AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN.") @ConfigEntry(path = "galaxy.type-weights", needsReset = true) @@ -2304,6 +2308,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWildIsletChance(double wildIsletChance) { this.wildIsletChance = wildIsletChance; } public int getWildIsletRadius() { return wildIsletRadius; } public void setWildIsletRadius(int wildIsletRadius) { this.wildIsletRadius = wildIsletRadius; } + public int getSpawnProtectionRange() { return spawnProtectionRange; } + public void setSpawnProtectionRange(int spawnProtectionRange) { this.spawnProtectionRange = spawnProtectionRange; } public Map getTypeWeights() { return typeWeights; } public void setTypeWeights(Map typeWeights) { this.typeWeights = typeWeights; } public double getFuelPerBlock() { return fuelPerBlock; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 61642bf..54e0f35 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -98,6 +98,25 @@ public boolean isUsesNewChunkGeneration() { return true; } + /** + * TradeWinds islands live at arbitrary seeded positions - never realign + * them to the grid (Stranger Realms pattern). + */ + @Override + public boolean isFixIslandCenter() { + return false; + } + + /** + * Island ranges vary here: trading islands use the full distance, the + * spawn island is small, and Stage 7 claims will size to their islet + * (Stranger Realms pattern). + */ + @Override + public boolean isEnforceEqualRanges() { + return false; + } + @Override public void onLoad() { // Save the default config from config.yml @@ -220,6 +239,7 @@ public void onEnable() { if (islandWorld != null) { int top = islandWorld.getHighestBlockYAt(0, 0); islandWorld.setSpawnLocation(0, Math.max(top + 1, getSettings().getSeaHeight() + 1), 0); + bootstrapSpawnIsland(top); } } @@ -330,6 +350,33 @@ public BiomeProvider getBiomeProvider() { return this.biomeProvider; } + /** + * The spawn islet is a real (small-range) BentoBox spawn island, so it is + * protected like any island - non-ops cannot grief it - and core spawn + * mechanics recognize it. Possible only because ranges are not enforced + * equal (see isEnforceEqualRanges). + */ + private void bootstrapSpawnIsland(int surfaceY) { + if (getIslands().getSpawn(islandWorld).isPresent()) { + return; + } + org.bukkit.Location center = new org.bukkit.Location(islandWorld, 0.5, + Math.max(surfaceY + 1, getSettings().getSeaHeight() + 1), 0.5); + world.bentobox.bentobox.database.objects.Island spawn = getIslands().createIsland(center, null, + getSettings().getSpawnProtectionRange()); + if (spawn == null) { + logError("Could not register the spawn island - spawn is unprotected"); + return; + } + // Small range: the spawn island must never crowd the starter cluster + // (starter centers can be as close as ~1250 per axis) + spawn.setRange(getSettings().getSpawnProtectionRange() * 2); + spawn.setName("Spawn"); + spawn.setSpawnPoint(Environment.NORMAL, center); + getIslands().setSpawn(spawn); + log("Registered the spawn island (protection " + getSettings().getSpawnProtectionRange() + ")"); + } + public PlayerDataManager getPlayerDataManager() { return playerDataManager; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a40dfd9..6ef3bc4 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -37,6 +37,8 @@ galaxy: wild-islet-chance: 0.3 # Terrain radius of wild islets. 0 disables them. wild-islet-radius: 70 + # Protection radius of the spawn island (covers the islet plus its shore). + spawn-protection-range: 100 # Relative spawn weight per island type. Higher = more common; 0 disables a type. type-weights: AGRICULTURAL: 10 diff --git a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java index c4a9a1d..7dc97b6 100644 --- a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java +++ b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java @@ -1,6 +1,7 @@ package world.bentobox.tradewinds; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -200,6 +201,15 @@ void testCreateWorldsNoInterstice() { assertNull(addon.getNetherWorld()); } + /** + * Stranger Realms pattern: arbitrary island positions and sizes. + */ + @Test + void testArbitraryIslandGeometryAllowed() { + assertFalse(addon.isFixIslandCenter()); + assertFalse(addon.isEnforceEqualRanges()); + } + /** * Test method for {@link TradeWinds#getSettings()}. */ From d75417e48c09559ca258db3080801b87ddf39485 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:45:40 -0700 Subject: [PATCH 024/112] Spawn island visitor allowances: boats, self-defense, crafting Re-asserted every enable so existing spawn islands update: BOAT, HURT_MONSTERS and CRAFTING at visitor rank; MONSTER_NATURAL_SPAWN, TNT_DAMAGE and BLOCK_EXPLODE_DAMAGE off. 128 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 6 ++- docs/PROGRESS.md | 9 ++++ .../world/bentobox/tradewinds/TradeWinds.java | 42 ++++++++++++------- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/TESTING.md b/TESTING.md index b2dc77a..013d66e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -333,7 +333,7 @@ have not visited). The protection flip and stamps work immediately. starchart, trade, restart, info, settings, language all work. - [ ] Non-op cannot use /twadmin or its subcommands. -### Stage 4j — protected spawn island +### Stage 4j — protected spawn island (updated: visitor allowances) - [ ] Console logs "Registered the spawn island (protection 100)" on first enable. - [ ] As NON-OP: breaking/placing blocks on the spawn islet is blocked; wild islets and open ocean remain free-build. @@ -341,5 +341,9 @@ have not visited). The protection flip and stamps work immediately. range 200 (small ranges are legal now — Stranger Realms overrides). - [ ] Restart: BentoBox loads cleanly (no "island distance mismatch" panic), spawn island persists as spawn. +- [ ] As NON-OP on the spawn island: placing/riding a BOAT works, hitting a monster + works, using a CRAFTING TABLE works — but block break/place is still blocked. +- [ ] No hostile mobs spawn on the spawn island; primed TNT there breaks no blocks + (flags re-asserted every enable, so the existing spawn island picks these up). - [ ] Trading islands still register/announce correctly (arbitrary centers untouched by the isFixIslandCenter override). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5b6e342..5ac088e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,15 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Spawn island visitor allowances (2026-07-30, playtest bug) + +Full protection was too much: visitors could not launch boats, defend +themselves or use a workbench at spawn. bootstrapSpawnIsland now (re-)asserts +policy every enable (existing spawn islands pick up changes): BOAT / +HURT_MONSTERS / CRAFTING at visitor rank; MONSTER_NATURAL_SPAWN, TNT_DAMAGE +and BLOCK_EXPLODE_DAMAGE settings off. (SAFE/POLICED trading islands already +had monster spawning off via bands config.) + ## Protected spawn island (2026-07-30) The protection flip left the spawn islet explicitly free-build (non-ops diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 54e0f35..0aad2a5 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -357,24 +357,34 @@ public BiomeProvider getBiomeProvider() { * equal (see isEnforceEqualRanges). */ private void bootstrapSpawnIsland(int surfaceY) { - if (getIslands().getSpawn(islandWorld).isPresent()) { - return; - } - org.bukkit.Location center = new org.bukkit.Location(islandWorld, 0.5, - Math.max(surfaceY + 1, getSettings().getSeaHeight() + 1), 0.5); - world.bentobox.bentobox.database.objects.Island spawn = getIslands().createIsland(center, null, - getSettings().getSpawnProtectionRange()); + world.bentobox.bentobox.database.objects.Island spawn = getIslands().getSpawn(islandWorld).orElse(null); if (spawn == null) { - logError("Could not register the spawn island - spawn is unprotected"); - return; + org.bukkit.Location center = new org.bukkit.Location(islandWorld, 0.5, + Math.max(surfaceY + 1, getSettings().getSeaHeight() + 1), 0.5); + spawn = getIslands().createIsland(center, null, getSettings().getSpawnProtectionRange()); + if (spawn == null) { + logError("Could not register the spawn island - spawn is unprotected"); + return; + } + // Small range: the spawn island must never crowd the starter cluster + // (starter centers can be as close as ~1250 per axis) + spawn.setRange(getSettings().getSpawnProtectionRange() * 2); + spawn.setName("Spawn"); + spawn.setSpawnPoint(Environment.NORMAL, center); + getIslands().setSpawn(spawn); + log("Registered the spawn island (protection " + getSettings().getSpawnProtectionRange() + ")"); } - // Small range: the spawn island must never crowd the starter cluster - // (starter centers can be as close as ~1250 per axis) - spawn.setRange(getSettings().getSpawnProtectionRange() * 2); - spawn.setName("Spawn"); - spawn.setSpawnPoint(Environment.NORMAL, center); - getIslands().setSpawn(spawn); - log("Registered the spawn island (protection " + getSettings().getSpawnProtectionRange() + ")"); + // (Re-)assert spawn island policy every enable, so existing spawn + // islands pick up rule changes too: + // - visitors may use boats (it is a harbor), fight monsters in + // self-defense, and use workbenches (craft a boat from wild timber) + spawn.setFlag(Flags.BOAT, 0); + spawn.setFlag(Flags.HURT_MONSTERS, 0); + spawn.setFlag(Flags.CRAFTING, 0); + // - nothing hostile spawns and TNT cannot blow the harbor apart + spawn.setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, false); + spawn.setSettingsFlag(Flags.TNT_DAMAGE, false); + spawn.setSettingsFlag(Flags.BLOCK_EXPLODE_DAMAGE, false); } public PlayerDataManager getPlayerDataManager() { From 4c30cbd360dbd992b9bb078b68418ac9c2f5774e Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:48:11 -0700 Subject: [PATCH 025/112] Show the hologram chart automatically on boarding a boat New players see where to go the moment they set sail - no telling needed. Config chart.show-on-boarding; holograms clear on quit. 128 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- .../world/bentobox/tradewinds/Settings.java | 7 +++++++ .../tradewinds/travel/ChartingListener.java | 17 +++++++++++++++++ src/main/resources/config.yml | 2 ++ 3 files changed, 26 insertions(+) diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 7f80df4..4a63961 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -198,6 +198,11 @@ private static Map defaultBandHurtVillagers() { @ConfigEntry(path = "chart.hologram-max") private int chartHologramMax = 12; + @ConfigComment("Show the hologram chart automatically whenever a player boards a boat -") + @ConfigComment("new players see where to go the moment they set sail, untold.") + @ConfigEntry(path = "chart.show-on-boarding") + private boolean chartOnBoarding = true; + @ConfigComment("Star Chart map scale: blocks per map pixel. 64 shows ~8km across;") @ConfigComment("smaller values zoom in (islands render larger).") @ConfigEntry(path = "chart.starchart-blocks-per-pixel") @@ -2352,6 +2357,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setChartHologramSeconds(int chartHologramSeconds) { this.chartHologramSeconds = chartHologramSeconds; } public int getChartHologramMax() { return chartHologramMax; } public void setChartHologramMax(int chartHologramMax) { this.chartHologramMax = chartHologramMax; } + public boolean isChartOnBoarding() { return chartOnBoarding; } + public void setChartOnBoarding(boolean chartOnBoarding) { this.chartOnBoarding = chartOnBoarding; } public int getStarChartBlocksPerPixel() { return starChartBlocksPerPixel; } public void setStarChartBlocksPerPixel(int starChartBlocksPerPixel) { this.starChartBlocksPerPixel = starChartBlocksPerPixel; } public boolean isNavigationBossbar() { return navigationBossbar; } diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java index 1f91d2a..1ca2d4a 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java @@ -8,6 +8,7 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.vehicle.VehicleEnterEvent; import net.kyori.adventure.text.Component; import world.bentobox.bentobox.api.user.User; @@ -39,6 +40,22 @@ public void onJoin(PlayerJoinEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { addon.getPlayerDataManager().unload(event.getPlayer().getUniqueId()); + addon.getChartHolograms().clear(event.getPlayer().getUniqueId()); + } + + /** + * Boarding a boat raises the hologram chart - the game shows new sailors + * where to go the moment they set sail, no telling needed. (Fades on its + * own; config chart.show-on-boarding.) + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBoardBoat(VehicleEnterEvent event) { + if (addon.getSettings().isChartOnBoarding() + && event.getVehicle() instanceof org.bukkit.entity.Boat + && event.getEntered() instanceof Player player + && player.getWorld().equals(addon.getOverWorld())) { + addon.getChartHolograms().show(player); + } } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 6ef3bc4..19b8fcd 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -128,6 +128,8 @@ chart: hologram-duration-seconds: 15 # Maximum holograms shown at once (nearest islands first). hologram-max: 12 + # Show the hologram chart automatically whenever a player boards a boat. + show-on-boarding: true # Star Chart map scale: blocks per map pixel. 64 shows ~8km across. starchart-blocks-per-pixel: 64 hud: From 98c9f79fe20aa34d366b0932ce2724a45192d2a5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:52:42 -0700 Subject: [PATCH 026/112] Teleporting while boated picks the boat (and cargo) up with you /tw spawn and any other teleport bring the hull and chest-boat contents into the inventory (overflow drops at the destination). The warp is untouched (it dismounts first); shared boats are never confiscated. 133 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- .../world/bentobox/tradewinds/TradeWinds.java | 3 + .../tradewinds/travel/BoatPickupListener.java | 89 ++++++++++++++ .../travel/BoatPickupListenerTest.java | 111 ++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 src/main/java/world/bentobox/tradewinds/travel/BoatPickupListener.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/BoatPickupListenerTest.java diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 0aad2a5..c9165aa 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -42,6 +42,7 @@ import world.bentobox.tradewinds.listeners.ResidentProtectionListener; import world.bentobox.tradewinds.tasks.NavigationBarTask; import world.bentobox.tradewinds.tasks.ResidentAuditTask; +import world.bentobox.tradewinds.travel.BoatPickupListener; import world.bentobox.tradewinds.travel.BorderPromptListener; import world.bentobox.tradewinds.travel.ChartHolograms; import world.bentobox.tradewinds.travel.ChartingListener; @@ -227,6 +228,8 @@ public void onEnable() { registerListener(starChartService); registerListener(new ChartingListener(this)); registerListener(new BorderPromptListener(this)); + // Teleporting while boated brings the boat (and cargo) along + registerListener(new BoatPickupListener(this)); // Residents survive the night: no mob targeting, tether, respawn registerListener(new ResidentProtectionListener()); residentAuditTask = new ResidentAuditTask(this); diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatPickupListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatPickupListener.java new file mode 100644 index 0000000..9fe93ab --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatPickupListener.java @@ -0,0 +1,89 @@ +package world.bentobox.tradewinds.travel; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Boat; +import org.bukkit.entity.ChestBoat; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.ItemStack; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Teleporting while boated brings the boat along: the boat (and any chest-boat + * cargo) is picked up into the player's inventory, so /tw spawn never strands + * a hull in the ocean. The warp is unaffected - it dismounts before + * teleporting, so the player is never mounted when its teleport fires. A boat + * carrying another player is never picked up (no stealing a shared boat by + * teleporting away). + * + * @author tastybento + */ +public class BoatPickupListener implements Listener { + + private final TradeWinds addon; + + public BoatPickupListener(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onTeleport(PlayerTeleportEvent event) { + Player player = event.getPlayer(); + if (!(player.getVehicle() instanceof Boat boat)) { + return; + } + if (!boat.getWorld().equals(addon.getOverWorld()) && !boat.getWorld().equals(addon.getNetherWorld())) { + return; + } + // Never confiscate a boat someone else is still sitting in + boolean shared = boat.getPassengers().stream() + .anyMatch(passenger -> passenger instanceof Player other && !other.equals(player)); + if (shared) { + player.leaveVehicle(); + return; + } + // Gather the hull and any cargo, then remove the entity + List items = new ArrayList<>(); + items.add(boatItem(boat)); + if (boat instanceof ChestBoat chestBoat) { + for (ItemStack stack : chestBoat.getInventory().getContents()) { + if (stack != null && !stack.getType().isAir()) { + items.add(stack.clone()); + } + } + chestBoat.getInventory().clear(); + } + boat.eject(); + boat.remove(); + // Into the inventory now (it travels with the player); overflow drops + // at the destination once the teleport has landed + List overflow = new ArrayList<>(); + items.forEach(item -> overflow.addAll(player.getInventory().addItem(item).values())); + if (!overflow.isEmpty()) { + Bukkit.getScheduler().runTask(addon.getPlugin(), + () -> overflow.forEach(item -> player.getWorld().dropItem(player.getLocation(), item))); + } + } + + /** + * The item form of a boat entity - entity type names and item material + * names match (OAK_BOAT, OAK_CHEST_BOAT...). + */ + static ItemStack boatItem(Boat boat) { + Material material; + try { + material = Material.valueOf(boat.getType().name()); + } catch (IllegalArgumentException e) { + material = Material.OAK_BOAT; + } + return new ItemStack(material); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/BoatPickupListenerTest.java b/src/test/java/world/bentobox/tradewinds/travel/BoatPickupListenerTest.java new file mode 100644 index 0000000..8826f90 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/BoatPickupListenerTest.java @@ -0,0 +1,111 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 java.util.HashMap; +import java.util.List; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Boat; +import org.bukkit.entity.ChestBoat; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Tests boat pickup on teleport: hull and cargo travel with the player; the + * warp path (already dismounted) and shared boats are untouched. + * + * @author tastybento + */ +class BoatPickupListenerTest extends CommonTestSetup { + + private TradeWinds addon; + private BoatPickupListener listener; + private PlayerInventory inventory; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + when(addon.getOverWorld()).thenReturn(world); + listener = new BoatPickupListener(addon); + inventory = mock(PlayerInventory.class); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + when(mockPlayer.getInventory()).thenReturn(inventory); + } + + private PlayerTeleportEvent teleport() { + return new PlayerTeleportEvent(mockPlayer, new Location(world, 0, 70, 0), + new Location(world, 500, 70, 500), PlayerTeleportEvent.TeleportCause.COMMAND); + } + + private Boat boat(Class clazz) { + Boat boat = mock(clazz); + when(boat.getWorld()).thenReturn(world); + when(boat.getPassengers()).thenReturn(List.of(mockPlayer)); + when(boat.getType()).thenReturn(org.bukkit.entity.EntityType.OAK_BOAT); + when(mockPlayer.getVehicle()).thenReturn(boat); + return boat; + } + + @Test + void testBoatPickedUpOnTeleport() { + Boat boat = boat(Boat.class); + listener.onTeleport(teleport()); + verify(boat).remove(); + verify(inventory).addItem(any(ItemStack.class)); + } + + @Test + void testChestBoatCargoComesAlong() { + ChestBoat boat = (ChestBoat) boat(ChestBoat.class); + when(boat.getType()).thenReturn(org.bukkit.entity.EntityType.OAK_CHEST_BOAT); + Inventory cargo = mock(Inventory.class); + when(cargo.getContents()).thenReturn(new ItemStack[] { new ItemStack(Material.COAL, 8), null }); + when(boat.getInventory()).thenReturn(cargo); + listener.onTeleport(teleport()); + verify(boat).remove(); + verify(cargo).clear(); + // Hull + one cargo stack + verify(inventory, org.mockito.Mockito.times(2)).addItem(any(ItemStack.class)); + } + + @Test + void testSharedBoatNeverConfiscated() { + Boat boat = boat(Boat.class); + Player other = mock(Player.class); + when(boat.getPassengers()).thenReturn(List.of(mockPlayer, other)); + listener.onTeleport(teleport()); + verify(boat, never()).remove(); + verify(mockPlayer).leaveVehicle(); + } + + @Test + void testUnmountedTeleportUntouched() { + when(mockPlayer.getVehicle()).thenReturn(null); + listener.onTeleport(teleport()); + verify(inventory, never()).addItem(any(ItemStack.class)); + } + + @Test + void testBoatItemMapping() { + Boat boat = mock(Boat.class); + when(boat.getType()).thenReturn(org.bukkit.entity.EntityType.CHERRY_BOAT); + assertEquals(Material.CHERRY_BOAT, BoatPickupListener.boatItem(boat).getType()); + } +} From 7e7fa90e1dc8610048c351ef4374e2305a7af158 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:57:19 -0700 Subject: [PATCH 027/112] Add stand-still friction to the warp (BentoBox delay covers /tw spawn) /tw spawn already uses DelayedTeleportCommand - it needs only commands.delay.time > 0 in BentoBox config (ops bypass). The warp fires from a dialog callback, so WarpService gained travel.warp.stand-still-seconds (default 0): moving during the countdown aborts the jump and salvages the fuel as charcoal. 133 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 10 ++++ docs/PROGRESS.md | 13 +++++ .../world/bentobox/tradewinds/Settings.java | 8 +++ .../tradewinds/travel/WarpService.java | 58 ++++++++++++++++++- src/main/resources/config.yml | 4 ++ src/main/resources/locales/en-US.yml | 2 + 6 files changed, 94 insertions(+), 1 deletion(-) diff --git a/TESTING.md b/TESTING.md index 013d66e..dacfd8a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -347,3 +347,13 @@ have not visited). The protection flip and stamps work immediately. (flags re-asserted every enable, so the existing spawn island picks these up). - [ ] Trading islands still register/announce correctly (arbitrary centers untouched by the isFixIslandCenter override). + +### Stage 4k — teleport friction (anti-escape) +Set `commands.delay.time: 3` in `plugins/BentoBox/config.yml` and test as a NON-OP +(ops and `tradewinds.mod.bypassdelays` holders always bypass delays): +- [ ] `/tw spawn` says "stand still for 3 seconds"; moving cancels the teleport. +- [ ] Set `travel.warp.stand-still-seconds: 3` in the TradeWinds config: engaging a + warp says "Hold your course..."; standing still completes the jump. +- [ ] Moving during the countdown aborts the warp and salvages the fuel back into the + hold as charcoal (dropped at your feet if the hold is full). +- [ ] With `stand-still-seconds: 0` (default) warps remain instant. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5ac088e..d47a3da 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,19 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Teleport friction: stand-still before warping (2026-07-30) + +Ben asked about BentoBox's stand-still-before-teleport. Finding: /tw spawn +already extends DelayedTeleportCommand and calls delayCommand, so it is +wired - the server just has commands.delay.time: 0, and ops bypass delays +anyway (which is why it never appeared in testing). +The warp teleports from a dialog callback, out of reach of the command-level +delay, so WarpService gained an equivalent: travel.warp.stand-still-seconds +(default 0 = instant, preserving the spec's fast warp until Ben tunes it). +Moving >2 blocks during the countdown aborts the jump and SALVAGES the fuel +as charcoal into the hold (never a silent loss); ops and +tradewinds.mod.bypassdelays bypass. 133 tests green. + ## Spawn island visitor allowances (2026-07-30, playtest bug) Full protection was too much: visitors could not launch boats, defend diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 4a63961..938de20 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -244,6 +244,12 @@ private static Map defaultBandHurtVillagers() { @ConfigEntry(path = "travel.warp.max-destinations") private int maxWarpDestinations = 8; + @ConfigComment("Seconds a player must hold still before an engaged warp fires (moving cancels it,") + @ConfigComment("fuel refunded). Stops the warp being a panic button in a fight or a customs chase.") + @ConfigComment("0 = instant. Ops and holders of tradewinds.mod.bypassdelays always warp instantly.") + @ConfigEntry(path = "travel.warp.stand-still-seconds") + private int warpStandStillSeconds = 0; + @ConfigComment("Seconds of nausea after a warp. Warping hurts - it gates the under-equipped.") @ConfigEntry(path = "travel.warp.nausea-seconds") private int warpNauseaSeconds = 8; @@ -2329,6 +2335,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWarpPromptCooldownSeconds(int warpPromptCooldownSeconds) { this.warpPromptCooldownSeconds = warpPromptCooldownSeconds; } public int getMaxWarpDestinations() { return maxWarpDestinations; } public void setMaxWarpDestinations(int maxWarpDestinations) { this.maxWarpDestinations = maxWarpDestinations; } + public int getWarpStandStillSeconds() { return warpStandStillSeconds; } + public void setWarpStandStillSeconds(int warpStandStillSeconds) { this.warpStandStillSeconds = warpStandStillSeconds; } public int getWarpNauseaSeconds() { return warpNauseaSeconds; } public void setWarpNauseaSeconds(int warpNauseaSeconds) { this.warpNauseaSeconds = warpNauseaSeconds; } public int getWarpBlindnessSeconds() { return warpBlindnessSeconds; } diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index b3ad390..fc2bad2 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -5,8 +5,10 @@ import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.Sound; +import org.bukkit.inventory.ItemStack; import org.bukkit.entity.Boat; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -115,7 +117,9 @@ private ActionButton button(Player player, IslandSpec origin, Destination dest) } /** - * Engage the warp: fuel is consumed here; the jump follows. + * Engage the warp: fuel is consumed here; the jump follows after any + * configured stand-still period (moving aborts it and the fuel is + * refunded - see {@link #standStillThen}). */ public void warp(Player player, IslandSpec from, IslandSpec to, int fuelCost) { TWWarpEvent event = new TWWarpEvent(player, from, to, fuelCost); @@ -127,6 +131,58 @@ public void warp(Player player, IslandSpec from, IslandSpec to, int fuelCost) { player.sendMessage(user(player).getTranslation("tradewinds.warp.not-enough-fuel")); return; } + standStillThen(player, () -> jump(player, from, to, fuelCost), fuelCost); + } + + /** + * Hold the player still for the configured seconds, then run the jump. + * Moving beyond a block aborts and refunds the fuel - the warp is a + * course, not a panic button. Ops and bypass-permission holders skip it. + */ + private void standStillThen(Player player, Runnable jump, int fuelCost) { + int seconds = addon.getSettings().getWarpStandStillSeconds(); + if (seconds <= 0 || player.isOp() + || player.hasPermission(addon.getPermissionPrefix() + "mod.bypassdelays")) { + jump.run(); + return; + } + Location start = player.getLocation().clone(); + user(player).sendMessage("tradewinds.warp.hold-course", "[seconds]", String.valueOf(seconds)); + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { + if (!player.isOnline()) { + return; + } + Location now = player.getLocation(); + if (!now.getWorld().equals(start.getWorld()) || now.distanceSquared(start) > 4.0) { + // Course broken: refund the fuel that was spent on engagement + refund(player, fuelCost); + user(player).sendMessage("tradewinds.warp.course-broken"); + return; + } + jump.run(); + }, seconds * 20L); + } + + /** + * Give back fuel value after an aborted warp, as charcoal into the hold + * (or at the player's feet if the hold is full). + */ + private void refund(Player player, int fuelUnits) { + double charcoalValue = Math.max(1.0, addon.getSettings().getFuelValues().getOrDefault("CHARCOAL", 3.0)); + int amount = (int) Math.ceil(fuelUnits / charcoalValue); + ItemStack refund = new ItemStack(Material.CHARCOAL, Math.max(1, amount)); + int added = addon.getHoldService().add(player, refund); + if (added < refund.getAmount()) { + ItemStack rest = refund.clone(); + rest.setAmount(refund.getAmount() - added); + player.getWorld().dropItem(player.getLocation(), rest); + } + } + + /** + * The jump itself: effects, teleport, re-seat. + */ + private void jump(Player player, IslandSpec from, IslandSpec to, int fuelCost) { // Departure effects Location here = player.getLocation(); here.getWorld().spawnParticle(Particle.PORTAL, here, 80, 1, 1, 1, 0.5); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 19b8fcd..d42c85d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -66,6 +66,10 @@ travel: # Maximum destinations listed in the warp dialog (nearest first). # 8 fits the dialog without scrolling - scrolling is easy to miss. max-destinations: 8 + # Seconds a player must hold still before an engaged warp fires (moving cancels it, + # fuel salvaged). Stops the warp being a panic button in a fight or customs chase. + # 0 = instant. Ops and tradewinds.mod.bypassdelays holders always warp instantly. + stand-still-seconds: 0 # Warping hurts - it gates the under-equipped. nausea-seconds: 8 blindness-seconds: 3 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index cae0996..2d6aee2 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -74,3 +74,5 @@ tradewinds: no-destinations: "&c No charted destinations - row into new waters first." not-enough-fuel: "&c Not enough fuel aboard for that jump." arrived: "&b The warp releases you at [name]." + hold-course: "&b Hold your course for [seconds] seconds - the warp is spinning up..." + course-broken: "&c You broke course - the warp fizzles out. Fuel salvaged." From ea7f57b29cd5157410986e0fbd4a80dab77b9c58 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 22:57:58 -0700 Subject: [PATCH 028/112] Make /tw warp and /tw trade op-only shortcuts Both bypassed the physical loop: warp from the dock instead of rowing to the border, trade without walking to a trader. The intended paths (border proximity prompt, right-clicking a plaza trader) remain open to everyone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 7 +++++-- src/main/resources/addon.yml | 14 ++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/TESTING.md b/TESTING.md index dacfd8a..c2e7080 100644 --- a/TESTING.md +++ b/TESTING.md @@ -329,8 +329,11 @@ have not visited). The protection flip and stamps work immediately. - [ ] Boatless + broke: wild-islet timber or /tw restart are the exits. ### Stage 4i — command permissions -- [ ] As a NON-OP player (test account, no LuckPerms grants): /tw, spawn, warp, chart, - starchart, trade, restart, info, settings, language all work. +- [ ] As a NON-OP player (test account, no LuckPerms grants): /tw, spawn, chart, + starchart, restart, info, settings, language all work. +- [ ] Non-op canNOT use /tw warp or /tw trade (op-only shortcuts) — but the intended + paths still work for them: rowing to an island border offers the warp dialog, + and right-clicking a plaza trader opens the market. - [ ] Non-op cannot use /twadmin or its subcommands. ### Stage 4j — protected spawn island (updated: visitor allowances) diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 81ee823..5f1bfae 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -16,8 +16,11 @@ permissions: description: Allow teleporting to the ocean spawn default: true tradewinds.island.warp: - description: Allow opening the warp dialog at island borders - default: true + description: > + Shortcut: open the warp dialog from anywhere in island waters. Off by + default - the intended path is to row to the island border, which offers + the dialog automatically. Grant to bypass the sailing. + default: op tradewinds.island.chart: description: Allow viewing the chart (holograms afloat, list ashore) default: true @@ -25,8 +28,11 @@ permissions: description: Allow receiving the Star Chart map default: true tradewinds.island.trade: - description: Allow trading at island markets - default: true + description: > + Shortcut: open the market from anywhere in an island's protection range. + Off by default - the intended path is to dock and right-click a trader + on the plaza. Grant to bypass the walk. + default: op tradewinds.island.restart: description: Allow restarting the trading career (config-capped) default: true From c69b0d19e842d3b9e700c12daa55604f586e800f Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 30 Jul 2026 23:09:44 -0700 Subject: [PATCH 029/112] Stage 5: risk at sea - the interstice and sea encounters Principle 2 (risk symmetry) was half-built: rowing was free AND safe. - Interstice: warps roll failure, stranding player+boat in the Nether sea partway along the route with Ghasts; the owed destination is remembered and re-engaged FREE via a repeating dialog (fallback to nearest charted island, so stranding is impossible). WarpService.deliver() split out. - Encounters: band- and distance-scaled rolls spawn day/night-appropriate mobs ahead of rowers - guardians, trident drowned, ZOMBIE_NAUTILUS, phantoms, pillager pirate crews and a sea witch in boats - with customs-stamped booty, making combat the third income stream. - 138 tests green Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 28 +++ docs/PROGRESS.md | 30 +++ .../world/bentobox/tradewinds/Settings.java | 77 +++++++ .../world/bentobox/tradewinds/TradeWinds.java | 25 +++ .../api/events/TWWarpFailedEvent.java | 50 +++++ .../encounters/EncounterListener.java | 55 +++++ .../encounters/EncounterService.java | 170 +++++++++++++++ .../tradewinds/encounters/EncounterTable.java | 56 +++++ .../tradewinds/encounters/EncounterType.java | 87 ++++++++ .../tradewinds/travel/IntersticeService.java | 202 ++++++++++++++++++ .../tradewinds/travel/WarpService.java | 25 ++- src/main/resources/config.yml | 37 ++++ src/main/resources/locales/en-US.yml | 9 + .../encounters/EncounterTableTest.java | 88 ++++++++ 14 files changed, 936 insertions(+), 3 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/api/events/TWWarpFailedEvent.java create mode 100644 src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java create mode 100644 src/main/java/world/bentobox/tradewinds/encounters/EncounterTable.java create mode 100644 src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java create mode 100644 src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java diff --git a/TESTING.md b/TESTING.md index c2e7080..c810dc7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -360,3 +360,31 @@ Set `commands.delay.time: 3` in `plugins/BentoBox/config.yml` and test as a NON- - [ ] Moving during the countdown aborts the warp and salvages the fuel back into the hold as charcoal (dropped at your feet if the hold is full). - [ ] With `stand-still-seconds: 0` (default) warps remain instant. + +## Stage 5 — Risk at sea + +**Half one: the interstice (warp risk).** Set `travel.warp.failure-chance: 1.0` +temporarily to force it. +- [ ] Warping drops you into `tradewinds_world_nether` partway along the route, in + your boat, with 1-3 Ghasts inbound and a "the warp collapses!" message. +- [ ] The re-engage dialog appears within ~20s (and repeats): clicking it delivers + you to the ORIGINAL destination for FREE — no second fuel charge. +- [ ] Stranding is impossible: relog in the interstice (losing the pending + destination) — the dialog still offers a free jump to your nearest charted + island. +- [ ] Ghast fireballs can be batted back; the fight is survivable in a boat. +- [ ] Restore `failure-chance: 0.05` afterwards. + +**Half two: sea encounters (rowing risk).** +- [ ] Row in open water far from islands: within a few rolls (45s each) mobs appear + ~28 blocks AHEAD of you — visible, so fleeing is a real choice. +- [ ] Day vs night differ: guardians by day, trident-throwing drowned at night. +- [ ] Lawless/anarchic water adds worse things: Zombie Nautilus (26.2's undead sea + horror), phantoms at night, pillager PIRATE CREWS in their own boat, and the + sea witch. +- [ ] Safe island waters are quiet (2% base, further reduced near the dock); + encounters intensify with distance from any island. +- [ ] Killing encounter mobs sometimes drops booty (nautilus shells, tridents, + ingots...) which IS customs-stamped — check it can be sold at a market. +- [ ] Only one encounter at a time per player; mobs despawn naturally when far away. +- [ ] `encounters.enabled: false` turns the whole system off. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d47a3da..efc2006 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,36 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 5 — Risk at sea (2026-07-30) — CODE COMPLETE, awaiting in-game test + +Built as one package because it IS one idea: spec principle 2 (risk symmetry) +was half-implemented - warping was to risk the interstice, rowing to risk the +lawless ocean, but rowing was free AND safe, quietly dominant for the patient. + +**Interstice (warp risk):** `IntersticeService` rolls +travel.warp.failure-chance at jump time; failure teleports player+boat to the +NETHER world partway along the route (35-65%), spawns +interstice.ghasts-min..max Ghasts targeting them, fires TWWarpFailedEvent. +The owed destination is remembered and the free re-engage dialog is offered +every interstice.prompt-seconds; taking it calls the new +`WarpService.deliver()` (the arrival half, split out of the jump) with no +fuel charge. Fallback if the pending destination is lost (relog): a free jump +to the nearest charted island - stranding must be impossible. + +**Sea encounters (rowing risk):** pure `EncounterTable` (chance scales with +distance from the nearest island, clamped 0.25-1.0 of the band's base; +roster filtered by band and day/night) + `EncounterService` (45s rolls for +players actually at sea, one encounter at a time, spawned +encounters.distance ahead so fleeing is always possible, PDC-tagged, +setRemoveWhenFarAway). Roster: guardian picket (day), trident drowned +(night), ZOMBIE_NAUTILUS (26.2's new undead sea mob, FRONTIER+), phantoms, +pillager pirate crews and a sea witch in their own boats (LAWLESS+). +`EncounterListener` drops **customs-stamped** booty - so fighting is the +third income beside honest margins and smuggled sugar, and the stamp system +lets us aim the faucet precisely. + +138 tests green. + ## Teleport friction: stand-still before warping (2026-07-30) Ben asked about BentoBox's stand-still-before-teleport. Finding: /tw spawn diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 938de20..830b7b4 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -396,6 +396,63 @@ private static Map defaultBasePrices() { return map; } + /* INTERSTICE */ + @ConfigComment("Ghasts spawned around a stranded sailor in the interstice.") + @ConfigEntry(path = "interstice.ghasts-min") + private int intersticeGhastsMin = 1; + + @ConfigEntry(path = "interstice.ghasts-max") + private int intersticeGhastsMax = 3; + + @ConfigComment("Seconds between offers of the free re-engage while adrift in the interstice.") + @ConfigEntry(path = "interstice.prompt-seconds") + private int intersticePromptSeconds = 20; + + /* SEA ENCOUNTERS */ + @ConfigComment("Random mob encounters at sea - the risk that balances free rowing against") + @ConfigComment("the warp's fuel cost and interstice risk.") + @ConfigEntry(path = "encounters.enabled") + private boolean encountersEnabled = true; + + @ConfigComment("Seconds between encounter rolls per player at sea.") + @ConfigEntry(path = "encounters.check-seconds") + private int encounterCheckSeconds = 45; + + @ConfigComment("Blocks ahead of the sailor that an encounter appears - far enough to see") + @ConfigComment("and flee from. Fleeing should always be a real option.") + @ConfigEntry(path = "encounters.distance") + private int encounterDistance = 28; + + @ConfigComment("Chance per roll of an encounter, by the security band of the nearest island") + @ConfigComment("(ANARCHIC also governs the deep ocean far from anywhere). Scaled by distance") + @ConfigComment("from that island: quiet by the docks, dangerous in open water.") + @ConfigEntry(path = "encounters.chance") + private Map encounterChance = defaultEncounterChance(); + + private static Map defaultEncounterChance() { + Map map = new HashMap<>(); + map.put("SAFE", 0.02); + map.put("POLICED", 0.06); + map.put("FRONTIER", 0.12); + map.put("LAWLESS", 0.20); + map.put("ANARCHIC", 0.28); + return map; + } + + @ConfigComment("Chance a spawned drowned carries a trident.") + @ConfigEntry(path = "encounters.drowned-trident-chance") + private double drownedTridentChance = 0.35; + + @ConfigComment("Chance that a slain encounter mob yields booty.") + @ConfigEntry(path = "encounters.booty-chance") + private double bootyChance = 0.5; + + @ConfigComment("Booty materials. Dropped customs-stamped, so salvage can be sold -") + @ConfigComment("fighting is the third way to earn, beside trading and smuggling.") + @ConfigEntry(path = "encounters.booty-table") + private List bootyTable = new ArrayList<>(List.of("NAUTILUS_SHELL", "PRISMARINE_SHARD", + "PRISMARINE_CRYSTALS", "GOLD_INGOT", "IRON_INGOT", "EMERALD", "COAL", "COOKED_COD", "TRIDENT")); + /* ILLEGAL TRADE */ @ConfigComment("Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling.") @ConfigComment("Set false for family-friendly servers - removes the entire crime layer cleanly.") @@ -2407,6 +2464,26 @@ public void setConcurrentIslands(int concurrentIslands) { public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } public Map getBasePrices() { return basePrices; } public void setBasePrices(Map basePrices) { this.basePrices = basePrices; } + public int getIntersticeGhastsMin() { return intersticeGhastsMin; } + public void setIntersticeGhastsMin(int intersticeGhastsMin) { this.intersticeGhastsMin = intersticeGhastsMin; } + public int getIntersticeGhastsMax() { return intersticeGhastsMax; } + public void setIntersticeGhastsMax(int intersticeGhastsMax) { this.intersticeGhastsMax = intersticeGhastsMax; } + public int getIntersticePromptSeconds() { return intersticePromptSeconds; } + public void setIntersticePromptSeconds(int intersticePromptSeconds) { this.intersticePromptSeconds = intersticePromptSeconds; } + public boolean isEncountersEnabled() { return encountersEnabled; } + public void setEncountersEnabled(boolean encountersEnabled) { this.encountersEnabled = encountersEnabled; } + public int getEncounterCheckSeconds() { return encounterCheckSeconds; } + public void setEncounterCheckSeconds(int encounterCheckSeconds) { this.encounterCheckSeconds = encounterCheckSeconds; } + public int getEncounterDistance() { return encounterDistance; } + public void setEncounterDistance(int encounterDistance) { this.encounterDistance = encounterDistance; } + public Map getEncounterChance() { return encounterChance; } + public void setEncounterChance(Map encounterChance) { this.encounterChance = encounterChance; } + public double getDrownedTridentChance() { return drownedTridentChance; } + public void setDrownedTridentChance(double drownedTridentChance) { this.drownedTridentChance = drownedTridentChance; } + public double getBootyChance() { return bootyChance; } + public void setBootyChance(double bootyChance) { this.bootyChance = bootyChance; } + public List getBootyTable() { return bootyTable; } + public void setBootyTable(List bootyTable) { this.bootyTable = bootyTable; } public boolean isIllegalTradeEnabled() { return illegalTradeEnabled; } public void setIllegalTradeEnabled(boolean illegalTradeEnabled) { this.illegalTradeEnabled = illegalTradeEnabled; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index c9165aa..676bcc8 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -35,6 +35,8 @@ import world.bentobox.tradewinds.dataobjects.IslandDataManager; import world.bentobox.tradewinds.dataobjects.PlayerDataManager; import world.bentobox.tradewinds.economy.MarketService; +import world.bentobox.tradewinds.encounters.EncounterListener; +import world.bentobox.tradewinds.encounters.EncounterService; import world.bentobox.tradewinds.economy.TradeDialog; import world.bentobox.tradewinds.economy.TradeListener; import world.bentobox.tradewinds.galaxy.RouteGraph; @@ -47,6 +49,7 @@ import world.bentobox.tradewinds.travel.ChartHolograms; import world.bentobox.tradewinds.travel.ChartingListener; import world.bentobox.tradewinds.travel.FuelService; +import world.bentobox.tradewinds.travel.IntersticeService; import world.bentobox.tradewinds.travel.HoldService; import world.bentobox.tradewinds.travel.StarChartService; import world.bentobox.tradewinds.travel.StarterKit; @@ -88,6 +91,8 @@ public class TradeWinds extends GameModeAddon { private TradeDialog tradeDialog; private ChartHolograms chartHolograms; private StarChartService starChartService; + private IntersticeService intersticeService; + private @Nullable EncounterService encounterService; private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; @@ -230,6 +235,12 @@ public void onEnable() { registerListener(new BorderPromptListener(this)); // Teleporting while boated brings the boat (and cargo) along registerListener(new BoatPickupListener(this)); + // Risk at sea: the interstice for warpers, encounters for rowers + intersticeService = new IntersticeService(this); + intersticeService.start(); + encounterService = new EncounterService(this); + encounterService.start(); + registerListener(new EncounterListener(this)); // Residents survive the night: no mob targeting, tether, respawn registerListener(new ResidentProtectionListener()); residentAuditTask = new ResidentAuditTask(this); @@ -254,6 +265,12 @@ public void onDisable() { if (navigationBarTask != null) { navigationBarTask.stop(); } + if (intersticeService != null) { + intersticeService.stop(); + } + if (encounterService != null) { + encounterService.stop(); + } if (chartHolograms != null) { chartHolograms.clearAll(); } @@ -434,6 +451,14 @@ public StarChartService getStarChartService() { return starChartService; } + public IntersticeService getIntersticeService() { + return intersticeService; + } + + public EncounterService getEncounterService() { + return encounterService; + } + /** * The player's legal standing, for HUDs. Until the reputation system * (Stage 6) lands, everyone is Clean. diff --git a/src/main/java/world/bentobox/tradewinds/api/events/TWWarpFailedEvent.java b/src/main/java/world/bentobox/tradewinds/api/events/TWWarpFailedEvent.java new file mode 100644 index 0000000..ee6a68a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/api/events/TWWarpFailedEvent.java @@ -0,0 +1,50 @@ +package world.bentobox.tradewinds.api.events; + +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Fired when a warp fails and drops the player into the interstice. The fuel + * was already spent at engagement - re-engaging to the same destination is + * free, so this is a detour, never a loss. + * + * @author tastybento + */ +public class TWWarpFailedEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final IslandSpec from; + private final IslandSpec to; + + public TWWarpFailedEvent(Player player, IslandSpec from, IslandSpec to) { + this.player = player; + this.from = from; + this.to = to; + } + + public Player getPlayer() { + return player; + } + + public IslandSpec getFrom() { + return from; + } + + public IslandSpec getTo() { + return to; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java new file mode 100644 index 0000000..25d6418 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java @@ -0,0 +1,55 @@ +package world.bentobox.tradewinds.encounters; + +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Booty: killing an encounter mob yields customs-stamped salvage - the only + * money the sea itself pays, and the adventurer's answer to the trader's + * margins and the smuggler's sugar. + * + * @author tastybento + */ +public class EncounterListener implements Listener { + + private final TradeWinds addon; + + public EncounterListener(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEncounterDeath(EntityDeathEvent event) { + if (!event.getEntity().getPersistentDataContainer().has(EncounterService.ENCOUNTER_KEY, + PersistentDataType.STRING)) { + return; + } + Player killer = event.getEntity().getKiller(); + if (killer == null) { + return; + } + List table = addon.getSettings().getBootyTable(); + if (table.isEmpty() || Math.random() >= addon.getSettings().getBootyChance()) { + return; + } + String entry = table.get((int) (Math.random() * table.size())); + Material material = Material.matchMaterial(entry); + if (material == null) { + addon.logError("Unknown material in encounters.booty-table: " + entry); + return; + } + // Stamped, so it can be sold: salvage has papers + ItemStack booty = addon.getMarketService().stamp(new ItemStack(material)); + event.getDrops().add(booty); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java new file mode 100644 index 0000000..9648f5a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java @@ -0,0 +1,170 @@ +package world.bentobox.tradewinds.encounters; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Sound; +import org.bukkit.entity.Boat; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Mob; +import org.bukkit.entity.Player; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.util.Vector; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Random sea encounters: the risk that makes rowing a choice rather than a + * free ride (spec principle 2 - warping risks the interstice, rowing risks the + * lawless ocean). Mobs appear at a visible distance ahead, so fleeing is + * always an option; killing them yields customs-stamped salvage, making + * adventuring a third income beside honest margins and smuggling. + * + * @author tastybento + */ +public class EncounterService { + + /** PDC key marking encounter mobs (booty, cleanup). */ + public static final NamespacedKey ENCOUNTER_KEY = NamespacedKey.fromString("tradewinds:encounter"); + + private final TradeWinds addon; + private BukkitTask task; + + public EncounterService(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + long period = Math.max(1, addon.getSettings().getEncounterCheckSeconds()) * 20L; + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::tick, period, period); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + } + + private void tick() { + if (!addon.getSettings().isEncountersEnabled() || addon.getOverWorld() == null) { + return; + } + for (Player player : addon.getOverWorld().getPlayers()) { + if (player.getGameMode() != org.bukkit.GameMode.SURVIVAL || player.isDead()) { + continue; + } + // At sea means at sea: in or on water, away from dry land + if (!isAtSea(player)) { + continue; + } + maybeSpawn(player); + } + } + + private boolean isAtSea(Player player) { + Location loc = player.getLocation(); + return loc.getBlock().getType() == Material.WATER + || (player.getVehicle() instanceof Boat + && loc.getBlock().getRelative(org.bukkit.block.BlockFace.DOWN).getType() == Material.WATER); + } + + private void maybeSpawn(Player player) { + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + int x = player.getLocation().getBlockX(); + int z = player.getLocation().getBlockZ(); + int range = addon.getSettings().getIslandDistance(); + // The governing band: the nearest island's, or ANARCHIC out in the deep + Optional nearest = engine.islandsNear(x, z, range).stream() + .min(java.util.Comparator.comparingLong(spec -> spec.distanceSquared(x, z))); + SecurityBand band = nearest.map(IslandSpec::band).orElse(SecurityBand.ANARCHIC); + double distance = nearest.map(spec -> Math.sqrt(spec.distanceSquared(x, z))).orElse((double) range); + double base = addon.getSettings().getEncounterChance().getOrDefault(band.name(), 0.0); + if (Math.random() >= EncounterTable.chance(distance, range, base)) { + return; + } + // One encounter at a time per player + if (countNearbyEncounterMobs(player) > 0) { + return; + } + boolean isDay = addon.getOverWorld().isDayTime(); + EncounterTable.pick(band, isDay, Math.random()).ifPresent(type -> spawn(player, type)); + } + + private int countNearbyEncounterMobs(Player player) { + return (int) player.getNearbyEntities(64, 32, 64).stream() + .filter(entity -> entity.getPersistentDataContainer().has(ENCOUNTER_KEY, PersistentDataType.STRING)) + .count(); + } + + /** + * Spawn an encounter ahead of the player, far enough to be seen and fled. + */ + public void spawn(Player player, EncounterType type) { + Location origin = player.getLocation(); + Vector ahead = origin.getDirection().setY(0); + if (ahead.lengthSquared() < 0.01) { + ahead = new Vector(1, 0, 0); + } + ahead.normalize().multiply(addon.getSettings().getEncounterDistance()); + Location spot = origin.clone().add(ahead); + spot.setY(addon.getSettings().getSeaHeight() + 1.0); + + int count = type.getMin() + + (int) (Math.random() * Math.max(1, type.getMax() - type.getMin() + 1)); + List spawned = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Location at = spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3); + Boat crewBoat = null; + if (type.isBoated()) { + crewBoat = (Boat) at.getWorld().spawnEntity(at, EntityType.OAK_BOAT); + tag(crewBoat); + spawned.add(crewBoat); + } + for (EntityType mobType : type.getMobs()) { + Entity entity = at.getWorld().spawnEntity(type.isBoated() ? at : at.clone().add(0, 0.5, 0), mobType); + equip(entity, mobType); + tag(entity); + if (entity instanceof Mob mob) { + mob.setTarget(player); + mob.setRemoveWhenFarAway(true); + } + if (crewBoat != null) { + crewBoat.addPassenger(entity); + } + spawned.add(entity); + } + } + if (spawned.isEmpty()) { + return; + } + User.getInstance(player).sendMessage("tradewinds.encounter." + type.name().toLowerCase(java.util.Locale.ENGLISH)); + player.playSound(player.getLocation(), Sound.AMBIENT_UNDERWATER_LOOP_ADDITIONS_RARE, 1.0f, 0.6f); + } + + /** + * Drowned throw tridents - that is what makes them dangerous to a boat. + */ + private void equip(Entity entity, EntityType type) { + if (type == EntityType.DROWNED && entity instanceof LivingEntity living && living.getEquipment() != null + && Math.random() < addon.getSettings().getDrownedTridentChance()) { + living.getEquipment().setItemInMainHand(new org.bukkit.inventory.ItemStack(Material.TRIDENT)); + living.getEquipment().setItemInMainHandDropChance(0.15f); + } + } + + private void tag(Entity entity) { + entity.getPersistentDataContainer().set(ENCOUNTER_KEY, PersistentDataType.STRING, "encounter"); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterTable.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterTable.java new file mode 100644 index 0000000..f937f8a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterTable.java @@ -0,0 +1,56 @@ +package world.bentobox.tradewinds.encounters; + +import java.util.List; +import java.util.Optional; + +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Pure encounter selection: how likely the sea is to find you, and what it + * sends. Risk rises with lawlessness and with distance from the nearest + * island - the water off a safe dock is quiet, the deep between two lawless + * islands is not. + * + * @author tastybento + */ +public final class EncounterTable { + + private EncounterTable() { + // Static use only + } + + /** + * Chance of an encounter on one roll. + * + * @param distanceFromIsland blocks to the nearest island center + * @param islandRange island space radius + * @param baseChance configured base chance for the governing band + * @return probability 0-1 + */ + public static double chance(double distanceFromIsland, int islandRange, double baseChance) { + // Inside island space the sea is tamer, scaling to full risk in the deep + double exposure = Math.clamp(distanceFromIsland / (double) islandRange, 0.25, 1.0); + return Math.clamp(baseChance * exposure, 0.0, 1.0); + } + + /** + * What the sea sends, if anything: a pick from the encounters that suit + * this band and time of day. + * + * @param band the governing band + * @param isDay daylight + * @param roll uniform 0-1 selection roll + * @return the encounter, or empty if nothing fits + */ + public static Optional pick(SecurityBand band, boolean isDay, double roll) { + List candidates = java.util.Arrays.stream(EncounterType.values()) + .filter(type -> band.ordinal() >= type.getMinBand().ordinal()) + .filter(type -> type.getTime().matches(isDay)) + .toList(); + if (candidates.isEmpty()) { + return Optional.empty(); + } + int index = Math.min(candidates.size() - 1, (int) (roll * candidates.size())); + return Optional.of(candidates.get(index)); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java new file mode 100644 index 0000000..057d6d2 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java @@ -0,0 +1,87 @@ +package world.bentobox.tradewinds.encounters; + +import java.util.List; + +import org.bukkit.entity.EntityType; + +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * The things that find you on the open sea. Each encounter names its mobs, the + * time it hunts, the minimum lawlessness that lets it happen, and how many + * turn up. + * + * @author tastybento + */ +public enum EncounterType { + + /** Slow swimmers that block the way ahead rather than chase - daytime nuisance. */ + GUARDIAN_PICKET(List.of(EntityType.GUARDIAN), Time.DAY, SecurityBand.POLICED, 1, 2, false), + /** Trident throwers: the real danger to a boat, after dark. */ + DROWNED_RAIDERS(List.of(EntityType.DROWNED), Time.NIGHT, SecurityBand.POLICED, 2, 3, false), + /** 26.2's undead sea horror - the deep water signature. */ + NAUTILUS_HORROR(List.of(EntityType.ZOMBIE_NAUTILUS), Time.ANY, SecurityBand.FRONTIER, 1, 2, false), + /** The sky is not safe either. */ + PHANTOM_FLIGHT(List.of(EntityType.PHANTOM), Time.NIGHT, SecurityBand.FRONTIER, 2, 3, false), + /** A crewed boat of raiders: NPC piracy, and a warning of the player kind. */ + PIRATE_CREW(List.of(EntityType.PILLAGER, EntityType.PILLAGER), Time.ANY, SecurityBand.LAWLESS, 1, 1, true), + /** A potion-throwing witch adrift in her own boat. */ + SEA_WITCH(List.of(EntityType.WITCH), Time.ANY, SecurityBand.LAWLESS, 1, 1, true); + + /** + * When an encounter hunts. + */ + public enum Time { + DAY, NIGHT, ANY; + + public boolean matches(boolean isDay) { + return this == ANY || (this == DAY) == isDay; + } + } + + private final List mobs; + private final Time time; + private final SecurityBand minBand; + private final int min; + private final int max; + private final boolean boated; + + EncounterType(List mobs, Time time, SecurityBand minBand, int min, int max, boolean boated) { + this.mobs = mobs; + this.time = time; + this.minBand = minBand; + this.min = min; + this.max = max; + this.boated = boated; + } + + public List getMobs() { + return mobs; + } + + public Time getTime() { + return time; + } + + /** + * @return the tamest band in which this encounter can happen at all + */ + public SecurityBand getMinBand() { + return minBand; + } + + public int getMin() { + return min; + } + + public int getMax() { + return max; + } + + /** + * @return true if the crew arrives in a boat of their own + */ + public boolean isBoated() { + return boated; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java new file mode 100644 index 0000000..41ba6d4 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -0,0 +1,202 @@ +package world.bentobox.tradewinds.travel; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.Boat; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Ghast; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.body.DialogBody; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.TWWarpFailedEvent; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * The interstice: warps sometimes fail, dropping the sailor into a hostile + * Nether sea partway along the route with Ghasts inbound. The fuel was spent + * at engagement, so re-engaging to the original destination is always + * free - the failure is a detour, never a loss, and stranding is + * impossible (spec §3.3). The way out is offered automatically while the + * player floats there. + * + * @author tastybento + */ +public class IntersticeService { + + /** Player -> the destination cell they are still owed, free of charge. */ + private final Map pendingDestination = new ConcurrentHashMap<>(); + private final Map lastPrompt = new ConcurrentHashMap<>(); + + private final TradeWinds addon; + private BukkitTask task; + + public IntersticeService(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::tick, 100L, 100L); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + } + + /** + * Does this warp fail? Pure roll against config (wanted players may face + * worse odds once reputation lands). + */ + public boolean rollFailure() { + return Math.random() < addon.getSettings().getWarpFailureChance(); + } + + /** + * Drop a player into the interstice partway along their route. + */ + public void strand(Player player, IslandSpec from, IslandSpec to) { + if (addon.getNetherWorld() == null) { + return; + } + pendingDestination.put(player.getUniqueId(), to.cellX() + "," + to.cellZ()); + // Partway along the route (seeded-ish: middle third) + double fraction = 0.35 + Math.random() * 0.3; + int x = (int) Math.round(from.centerX() + (to.centerX() - from.centerX()) * fraction); + int z = (int) Math.round(from.centerZ() + (to.centerZ() - from.centerZ()) * fraction); + Location target = new Location(addon.getNetherWorld(), x + 0.5, + addon.getSettings().getIntersticeSeaHeight() + 1.0, z + 0.5); + + Entity vehicle = player.getVehicle(); + if (vehicle != null) { + player.leaveVehicle(); + } + Util.teleportAsync(player, target).thenRun(() -> { + if (vehicle instanceof Boat boat && boat.isValid()) { + boat.teleportAsync(target).thenRun(() -> Bukkit.getScheduler().runTask(addon.getPlugin(), + () -> boat.addPassenger(player))); + } + target.getWorld().spawnParticle(Particle.PORTAL, target, 120, 2, 2, 2, 0.6); + target.getWorld().playSound(target, Sound.ENTITY_GHAST_SCREAM, 1.0f, 0.7f); + User.getInstance(player).sendMessage("tradewinds.interstice.stranded"); + spawnGhasts(player, target); + Bukkit.getPluginManager().callEvent(new TWWarpFailedEvent(player, from, to)); + }); + } + + private void spawnGhasts(Player player, Location around) { + int min = addon.getSettings().getIntersticeGhastsMin(); + int count = min + (int) (Math.random() * Math.max(1, + addon.getSettings().getIntersticeGhastsMax() - min + 1)); + for (int i = 0; i < count; i++) { + double angle = Math.random() * Math.PI * 2; + double distance = 20 + Math.random() * 15; + Location spot = around.clone().add(Math.cos(angle) * distance, 12 + Math.random() * 8, + Math.sin(angle) * distance); + Entity ghast = around.getWorld().spawnEntity(spot, EntityType.GHAST); + if (ghast instanceof Ghast g) { + g.setTarget(player); + g.setRemoveWhenFarAway(true); + } + } + } + + /** + * Offer the free re-engage to interstice castaways, and clean up. + */ + private void tick() { + if (addon.getNetherWorld() == null) { + return; + } + long now = System.currentTimeMillis() / 1000; + for (Player player : addon.getNetherWorld().getPlayers()) { + long last = lastPrompt.getOrDefault(player.getUniqueId(), 0L); + if (now - last < addon.getSettings().getIntersticePromptSeconds()) { + continue; + } + lastPrompt.put(player.getUniqueId(), now); + openReEngageDialog(player); + } + } + + /** + * The way home: the owed destination free of charge, or - if that is + * somehow lost (a relog, an admin teleport) - any charted island, also + * free. Nobody is ever stranded. + */ + public void openReEngageDialog(Player player) { + Optional destination = owedDestination(player); + IslandSpec target = destination.orElseGet(() -> nearestCharted(player)); + if (target == null) { + return; + } + ActionButton engage = ActionButton.create( + Component.text("Re-engage the warp to " + target.name(), NamedTextColor.AQUA), + Component.text("Free - the fuel was spent when you first engaged"), 320, + DialogAction.customClick((response, audience) -> reEngage(player, target), + ClickCallback.Options.builder().build())); + Dialog dialog = Dialog.create(factory -> factory.empty() + .base(DialogBase.builder(Component.text("The Interstice")) + .body(java.util.List.of(DialogBody.plainMessage(Component.text( + "Dark water, and something screaming above it. Your course is still paid for.", + NamedTextColor.GRAY)))) + .build()) + .type(DialogType.multiAction(java.util.List.of(engage)).columns(1).build())); + player.showDialog(dialog); + } + + private void reEngage(Player player, IslandSpec target) { + pendingDestination.remove(player.getUniqueId()); + lastPrompt.remove(player.getUniqueId()); + addon.getWarpService().deliver(player, target); + } + + private Optional owedDestination(Player player) { + String cell = pendingDestination.get(player.getUniqueId()); + if (cell == null) { + return Optional.empty(); + } + String[] parts = cell.split(","); + return addon.getGalaxyEngine(addon.getOverWorld().getSeed()) + .islandInCell(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + } + + private IslandSpec nearestCharted(Player player) { + var engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + return addon.getPlayerDataManager().get(player.getUniqueId()).getChartedIslands().stream() + .map(key -> key.split(",")) + .map(cell -> engine.islandInCell(Integer.parseInt(cell[0]), Integer.parseInt(cell[1]))) + .flatMap(Optional::stream) + .min(java.util.Comparator.comparingLong( + spec -> spec.distanceSquared(player.getLocation().getBlockX(), + player.getLocation().getBlockZ()))) + .orElse(null); + } + + /** + * Whether a player is owed a free jump (test/API visibility). + */ + public boolean hasPendingDestination(UUID playerId) { + return pendingDestination.containsKey(playerId); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index fc2bad2..96d7647 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -180,7 +180,8 @@ private void refund(Player player, int fuelUnits) { } /** - * The jump itself: effects, teleport, re-seat. + * The jump itself: departure effects, then either the interstice (on a + * failed warp) or delivery to the destination. */ private void jump(Player player, IslandSpec from, IslandSpec to, int fuelCost) { // Departure effects @@ -190,7 +191,25 @@ private void jump(Player player, IslandSpec from, IslandSpec to, int fuelCost) { // Arrive close to the destination, on the bearing of the origin - // inside view distance, so the island is right there in front of you - int[] arrive = RouteGraph.arrivalPoint(from, to, addon.getSettings().getWarpArrivalDistance()); + // The warp does not always hold: a failure drops the sailor into the + // interstice, still owed this destination (spec 3.3) + if (addon.getIntersticeService().rollFailure()) { + addon.getIntersticeService().strand(player, from, to); + return; + } + deliver(player, to, from, fuelCost); + } + + /** + * Deliver a player (and their boat) to an island - the arrival half of a + * warp. Also used by the interstice's free re-engage. + */ + public void deliver(Player player, IslandSpec to) { + deliver(player, to, to, 0); + } + + private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int fuelCost) { + int[] arrive = RouteGraph.arrivalPoint(bearingFrom, to, addon.getSettings().getWarpArrivalDistance()); Location target = new Location(addon.getOverWorld(), arrive[0] + 0.5, addon.getSettings().getSeaHeight() + 1.0, arrive[1] + 0.5); @@ -206,7 +225,7 @@ private void jump(Player player, IslandSpec from, IslandSpec to, int fuelCost) { } arrivalEffects(player, target); player.sendMessage(user(player).getTranslation("tradewinds.warp.arrived", "[name]", to.name())); - Bukkit.getPluginManager().callEvent(new TWWarpCompletedEvent(player, from, to, fuelCost)); + Bukkit.getPluginManager().callEvent(new TWWarpCompletedEvent(player, bearingFrom, to, fuelCost)); }); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d42c85d..797459e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -204,6 +204,43 @@ economy: player: # Career restarts a destitute player may use (/tw restart). -1 unlimited, 0 none. max-restarts: 3 +interstice: + # Ghasts spawned around a stranded sailor. + ghasts-min: 1 + ghasts-max: 3 + # Seconds between offers of the free re-engage while adrift. + prompt-seconds: 20 +encounters: + # Random mob encounters at sea - the risk that balances free rowing against + # the warp's fuel cost and interstice risk. + enabled: true + # Seconds between encounter rolls per player at sea. + check-seconds: 45 + # Blocks ahead of the sailor an encounter appears - far enough to see and flee. + distance: 28 + # Chance per roll, by the security band of the nearest island (ANARCHIC also + # governs the deep ocean). Scaled by distance from that island. + chance: + SAFE: 0.02 + POLICED: 0.06 + FRONTIER: 0.12 + LAWLESS: 0.20 + ANARCHIC: 0.28 + # Chance a spawned drowned carries a trident. + drowned-trident-chance: 0.35 + # Chance that a slain encounter mob yields booty, and what it may drop. + # Booty is customs-stamped, so it can be sold: fighting is the third way to earn. + booty-chance: 0.5 + booty-table: + - NAUTILUS_SHELL + - PRISMARINE_SHARD + - PRISMARINE_CRYSTALS + - GOLD_INGOT + - IRON_INGOT + - EMERALD + - COAL + - COOKED_COD + - TRIDENT illegal-trade: # Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling. # Set false for family-friendly servers - removes the entire crime layer cleanly. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 2d6aee2..11341d6 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -68,6 +68,15 @@ tradewinds: no-hold-space: "&c No room in your hold - stow cargo in your chest boat, expanders or bundles." expander-bought: "&6 Cargo expander purchased for $[price] - stowed in your hold." expander-cap: "&c Your boat cannot take any more expanders." + interstice: + stranded: "&4 The warp collapses! Dark water, and screaming above it." + encounter: + guardian_picket: "&3 Guardians rise in your path." + drowned_raiders: "&3 Drowned surface off the bow - some carry tridents." + nautilus_horror: "&5 Something vast and dead uncoils beneath the hull." + phantom_flight: "&3 Wings in the dark above the mast." + pirate_crew: "&c A crewed boat turns toward you. Pirates." + sea_witch: "&5 A witch adrift, and she has seen you." warp: not-in-boat: "&c You need to be aboard a boat to warp." not-at-island: "&c The warp only engages within an island's waters." diff --git a/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java b/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java new file mode 100644 index 0000000..346f947 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java @@ -0,0 +1,88 @@ +package world.bentobox.tradewinds.encounters; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Pure tests of sea-encounter selection: risk rises with lawlessness and open + * water, rosters respect band and time of day. + * + * @author tastybento + */ +class EncounterTableTest { + + @Test + void testRiskRisesWithDistance() { + double nearDock = EncounterTable.chance(50, 1000, 0.2); + double midWater = EncounterTable.chance(500, 1000, 0.2); + double deep = EncounterTable.chance(1000, 1000, 0.2); + assertTrue(nearDock < midWater, "Open water must be riskier than the harbor"); + assertTrue(midWater < deep); + assertEquals(0.2, deep, 0.0001); + // Clamped: never zero risk right at the dock, never above the base + assertEquals(0.05, EncounterTable.chance(0, 1000, 0.2), 0.0001); + assertEquals(0.2, EncounterTable.chance(99999, 1000, 0.2), 0.0001); + } + + @Test + void testSafeWatersAreTamest() { + // Nothing at all hunts in SAFE space: every encounter needs POLICED+ + assertTrue(EncounterTable.pick(SecurityBand.SAFE, true, 0.5).isEmpty()); + assertTrue(EncounterTable.pick(SecurityBand.SAFE, false, 0.5).isEmpty()); + } + + @Test + void testDayAndNightRosters() { + // Day in policed water: guardians, never drowned raiders + Set day = rollAll(SecurityBand.POLICED, true); + assertTrue(day.contains(EncounterType.GUARDIAN_PICKET)); + assertFalse(day.contains(EncounterType.DROWNED_RAIDERS)); + // Night: trident drowned, no guardian picket + Set night = rollAll(SecurityBand.POLICED, false); + assertTrue(night.contains(EncounterType.DROWNED_RAIDERS)); + assertFalse(night.contains(EncounterType.GUARDIAN_PICKET)); + } + + @Test + void testLawlessnessUnlocksWorseThings() { + Set policed = rollAll(SecurityBand.POLICED, true); + assertFalse(policed.contains(EncounterType.NAUTILUS_HORROR)); + assertFalse(policed.contains(EncounterType.PIRATE_CREW)); + Set frontier = rollAll(SecurityBand.FRONTIER, true); + assertTrue(frontier.contains(EncounterType.NAUTILUS_HORROR)); + assertFalse(frontier.contains(EncounterType.PIRATE_CREW)); + Set anarchic = rollAll(SecurityBand.ANARCHIC, true); + assertTrue(anarchic.contains(EncounterType.PIRATE_CREW)); + assertTrue(anarchic.contains(EncounterType.SEA_WITCH)); + } + + @Test + void testCrewsArriveByBoat() { + assertTrue(EncounterType.PIRATE_CREW.isBoated()); + assertTrue(EncounterType.SEA_WITCH.isBoated()); + assertFalse(EncounterType.DROWNED_RAIDERS.isBoated()); + // Nothing overwhelms a boat: encounters stay small + for (EncounterType type : EncounterType.values()) { + assertTrue(type.getMax() <= 3, type + " is too large a pack for a rowboat"); + assertTrue(type.getMin() >= 1 && type.getMin() <= type.getMax()); + } + } + + private Set rollAll(SecurityBand band, boolean isDay) { + Set seen = new HashSet<>(); + for (int i = 0; i < 100; i++) { + Optional pick = EncounterTable.pick(band, isDay, i / 100.0); + pick.ifPresent(seen::add); + } + return seen; + } +} From ae11597e168332ca7922d7b32fb266b36f9c5b09 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 07:21:03 -0700 Subject: [PATCH 030/112] Make spawn a real trading island at the origin Reserve the origin cell for a full SAFE trading island named Spawn (economy via galaxy.spawn-island-type, FISHING default) - dock, plaza, market, villagers, boss bar and warp zone all come from the existing systems, and other islands are pushed clear so min separation holds. It is registered and designated BentoBox's spawn island (setSpawn + spawn point on the plaza), so /tw spawn lands players in a working port and admins can rename/move/reflag it normally. The bare spawn islet is gone. 140 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 22 ++++++ docs/PROGRESS.md | 22 ++++++ .../world/bentobox/tradewinds/Settings.java | 20 ++--- .../world/bentobox/tradewinds/TradeWinds.java | 74 +++++++++++-------- .../tradewinds/galaxy/GalaxyConfig.java | 19 +++-- .../tradewinds/galaxy/GalaxyEngine.java | 41 ++++++---- .../generator/GalaxyIslandRegistrar.java | 27 +++++-- src/main/resources/config.yml | 9 +-- .../tradewinds/galaxy/GalaxyEngineTest.java | 37 +++++++--- .../generator/ChunkGeneratorWorldTest.java | 11 +-- .../TradeWindsBiomeProviderTest.java | 12 +-- .../travel/ChartNavigationTest.java | 11 ++- 12 files changed, 208 insertions(+), 97 deletions(-) diff --git a/TESTING.md b/TESTING.md index c810dc7..2a38db9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -388,3 +388,25 @@ temporarily to force it. ingots...) which IS customs-stamped — check it can be sold at a market. - [ ] Only one encounter at a time per player; mobs despawn naturally when far away. - [ ] `encounters.enabled: false` turns the whole system off. + +## Stage 5b — Spawn is a real trading island + +**World note**: the origin now hosts a full trading island instead of the bare islet. +Stop the server, delete the four origin region files (`tradewinds_world/region/r.0.0.mca`, +`r.-1.0.mca`, `r.0.-1.mca`, `r.-1.-1.mca`) AND the old spawn island from the TradeWinds +database (or use `/twadmin delete` on it), then restart. + +- [ ] Console logs "Designated Spawn (FISHING) as the spawn island" on first enable. +- [ ] `/tw` puts you ON the market plaza of an island named **Spawn** — villagers, + stalls, landmark, the lot. Trading is available from second one. +- [ ] Walk down the dock, place your boat, and sail: the border warp prompt appears at + the island edge like any other island. The navigation boss bar shows "Spawn". +- [ ] The island is SAFE band: no hostile spawns, no PvP, villagers protected — plus + the harbor allowances (boats, workbenches, hitting monsters) for visitors. +- [ ] Non-op still cannot break blocks there; TNT breaks nothing. +- [ ] Die without a bed: respawn on the Spawn plaza. +- [ ] `galaxy.spawn-island-type` picks the economy (FISHING default, RANDOM allowed) — + needs a world regen to change. +- [ ] Admin control works as normal BentoBox: `/twadmin setname`, `/twadmin + setspawnpoint`, `/twadmin settings` on the spawn island. +- [ ] No other island is closer than 2500 blocks to spawn (nothing crowds the port). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index efc2006..03ad69a 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,28 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 5b — Spawn is a real trading island (2026-07-31) + +Ben: make spawn a proper named island with dock, plaza and a config-picked +economy, designated as BentoBox's spawn - and preferably by reusing an +existing island rather than special-casing 0,0. Done by reserving the ORIGIN +CELL for it, which deleted more code than it added: the galaxy engine returns +a full SAFE trading island named "Spawn" centered exactly at 0,0 (economy from +galaxy.spawn-island-type, default FISHING, RANDOM allowed), so dock, plaza, +market, villagers, boss bar and border warp zone all come for free from the +existing systems. Other cells' islands are pushed radially clear of the origin +so min separation still holds (tested). The bare spawn islet and its +protection-range config are gone. +`bootstrapSpawnIsland` now: computes the plaza position from pure geometry (no +chunk load needed), sets it as the world spawn, registers the island via a +new reusable `GalaxyIslandRegistrar.register(spec, world)`, calls +`setSpawnPoint` + `IslandsManager.setSpawn` once (so admins can rename, move +the spawn point and change flags afterwards with normal BentoBox commands), +and re-asserts the harbor allowances every enable. +Players now spawn on a working market plaza with coal, money and a boat - +trade immediately, then walk to the dock and sail. No long empty row to start. +140 tests green (three tests had assumed 0,0 was open ocean). + ## Stage 5 — Risk at sea (2026-07-30) — CODE COMPLETE, awaiting in-game test Built as one package because it IS one idea: spec principle 2 (risk symmetry) diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 830b7b4..a89e083 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -95,10 +95,12 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.band-radius", needsReset = true) private int bandRadius = 5000; - @ConfigComment("Radius of the safe spawn islet generated at the world origin - players spawn") - @ConfigComment("and respawn there. 0 disables it (not recommended: seabed respawns kill).") - @ConfigEntry(path = "galaxy.spawn-islet-radius", needsReset = true) - private int spawnIsletRadius = 48; + @ConfigComment("Economy of the spawn island - the trading island reserved at the world origin,") + @ConfigComment("where players spawn and respawn. A working port: dock, plaza, market, warp zone.") + @ConfigComment("One of AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN,") + @ConfigComment("or RANDOM to let the seed decide.") + @ConfigEntry(path = "galaxy.spawn-island-type", needsReset = true) + private String spawnIslandType = "FISHING"; @ConfigComment("Chance (0-1) that a galaxy cell WITHOUT a trading island hosts a wild islet -") @ConfigComment("small unnamed islands, unprotected: mine, farm, build, live. Minecraft-stuff land.") @@ -109,10 +111,6 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.wild-islet-radius", needsReset = true) private int wildIsletRadius = 70; - @ConfigComment("Protection radius of the spawn island (covers the islet plus its shore).") - @ConfigEntry(path = "galaxy.spawn-protection-range") - private int spawnProtectionRange = 100; - @ConfigComment("Relative spawn weight per island type. Higher = more common; 0 disables a type.") @ConfigComment("Types: AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN.") @ConfigEntry(path = "galaxy.type-weights", needsReset = true) @@ -2370,14 +2368,12 @@ public void setConcurrentIslands(int concurrentIslands) { public void setLandLift(int landLift) { this.landLift = landLift; } public int getBandRadius() { return bandRadius; } public void setBandRadius(int bandRadius) { this.bandRadius = bandRadius; } - public int getSpawnIsletRadius() { return spawnIsletRadius; } - public void setSpawnIsletRadius(int spawnIsletRadius) { this.spawnIsletRadius = spawnIsletRadius; } + public String getSpawnIslandType() { return spawnIslandType; } + public void setSpawnIslandType(String spawnIslandType) { this.spawnIslandType = spawnIslandType; } public double getWildIsletChance() { return wildIsletChance; } public void setWildIsletChance(double wildIsletChance) { this.wildIsletChance = wildIsletChance; } public int getWildIsletRadius() { return wildIsletRadius; } public void setWildIsletRadius(int wildIsletRadius) { this.wildIsletRadius = wildIsletRadius; } - public int getSpawnProtectionRange() { return spawnProtectionRange; } - public void setSpawnProtectionRange(int spawnProtectionRange) { this.spawnProtectionRange = spawnProtectionRange; } public Map getTypeWeights() { return typeWeights; } public void setTypeWeights(Map typeWeights) { this.typeWeights = typeWeights; } public double getFuelPerBlock() { return fuelPerBlock; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 676bcc8..fdf1f22 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -248,12 +248,10 @@ public void onEnable() { // Navigation boss bar: island, standing, distance to dock navigationBarTask = new NavigationBarTask(this); navigationBarTask.start(); - // Spawn (and bed-less respawn) is on the spawn islet's surface + // Spawn (and bed-less respawn) is the spawn island's market plaza registerListener(new world.bentobox.tradewinds.listeners.SpawnRespawnListener(this)); if (islandWorld != null) { - int top = islandWorld.getHighestBlockYAt(0, 0); - islandWorld.setSpawnLocation(0, Math.max(top + 1, getSettings().getSeaHeight() + 1), 0); - bootstrapSpawnIsland(top); + bootstrapSpawnIsland(); } } @@ -371,37 +369,39 @@ public BiomeProvider getBiomeProvider() { } /** - * The spawn islet is a real (small-range) BentoBox spawn island, so it is - * protected like any island - non-ops cannot grief it - and core spawn - * mechanics recognize it. Possible only because ranges are not enforced - * equal (see isEnforceEqualRanges). + * Designate the origin trading island as BentoBox's spawn island: a + * working port where players arrive, meet the market, and set sail from + * the dock - no long empty row to start. Admins can rename it, move its + * spawn point and change its flags with the usual BentoBox commands + * afterwards; only the first registration sets these. */ - private void bootstrapSpawnIsland(int surfaceY) { - world.bentobox.bentobox.database.objects.Island spawn = getIslands().getSpawn(islandWorld).orElse(null); + private void bootstrapSpawnIsland() { + GalaxyEngine engine = getGalaxyEngine(islandWorld.getSeed()); + world.bentobox.tradewinds.galaxy.IslandSpec spec = engine.spawnIsland(); + // The plaza is deterministic geometry - no chunk needs to be loaded + world.bentobox.tradewinds.galaxy.DockPlan plan = engine.dockPlan(spec); + org.bukkit.Location plaza = new org.bukkit.Location(islandWorld, plan.plazaX() + 0.5, + getSettings().getSeaHeight() + GalaxyEngine.PLAZA_RISE + 1.0, plan.plazaZ() + 0.5); + islandWorld.setSpawnLocation(plaza); + + GalaxyIslandRegistrar registrar = new GalaxyIslandRegistrar(this); + world.bentobox.bentobox.database.objects.Island spawn = getIslands().getSpawn(islandWorld) + .orElseGet(() -> registrar.register(spec, islandWorld)); if (spawn == null) { - org.bukkit.Location center = new org.bukkit.Location(islandWorld, 0.5, - Math.max(surfaceY + 1, getSettings().getSeaHeight() + 1), 0.5); - spawn = getIslands().createIsland(center, null, getSettings().getSpawnProtectionRange()); - if (spawn == null) { - logError("Could not register the spawn island - spawn is unprotected"); - return; - } - // Small range: the spawn island must never crowd the starter cluster - // (starter centers can be as close as ~1250 per axis) - spawn.setRange(getSettings().getSpawnProtectionRange() * 2); - spawn.setName("Spawn"); - spawn.setSpawnPoint(Environment.NORMAL, center); + logError("Could not register the spawn island - spawn is unprotected"); + return; + } + if (!spawn.isSpawn()) { + spawn.setSpawnPoint(Environment.NORMAL, plaza); getIslands().setSpawn(spawn); - log("Registered the spawn island (protection " + getSettings().getSpawnProtectionRange() + ")"); + log("Designated " + spec.name() + " (" + spec.type() + ") as the spawn island"); } - // (Re-)assert spawn island policy every enable, so existing spawn - // islands pick up rule changes too: - // - visitors may use boats (it is a harbor), fight monsters in - // self-defense, and use workbenches (craft a boat from wild timber) + // Spawn-port allowances, re-asserted every enable: it is a harbor, so + // boats, self-defense and workbenches are everyone's right, and + // nothing hostile spawns or explodes here spawn.setFlag(Flags.BOAT, 0); spawn.setFlag(Flags.HURT_MONSTERS, 0); spawn.setFlag(Flags.CRAFTING, 0); - // - nothing hostile spawns and TNT cannot blow the harbor apart spawn.setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, false); spawn.setSettingsFlag(Flags.TNT_DAMAGE, false); spawn.setSettingsFlag(Flags.BLOCK_EXPLODE_DAMAGE, false); @@ -485,12 +485,28 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { galaxyEngine = new GalaxyEngine(new GalaxyConfig(seed, s.getGalaxyMinSeparation(), s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight(), typeWeights(), - s.getSpawnIsletRadius(), s.getWildIsletChance(), s.getWildIsletRadius())); + spawnIslandType(), s.getWildIsletChance(), s.getWildIsletRadius())); log("TradeWinds galaxy seed: " + seed); } return galaxyEngine; } + /** + * The configured spawn island economy, or null to let the seed roll it. + */ + private IslandType spawnIslandType() { + String name = getSettings().getSpawnIslandType(); + if (name == null || name.isBlank() || "RANDOM".equalsIgnoreCase(name)) { + return null; + } + try { + return IslandType.valueOf(name.toUpperCase(java.util.Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + logError("Unknown galaxy.spawn-island-type: " + name + " - using a seeded type"); + return null; + } + } + /** * Parse the configured island type weights; unknown type names are logged * and skipped (a zero total falls back to built-in defaults inside diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java index d2c5bda..febaec7 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -21,7 +21,8 @@ * @param seaLevel the world's sea surface Y - dock and plaza heights hang off it * @param typeWeights relative spawn weight per island type; a zero or missing * total falls back to the built-in defaults - * @param spawnIsletRadius radius of the safe spawn islet at the origin (0 = none) + * @param spawnIslandType economy of the trading island reserved at the origin + * (null = seeded roll like any other island) * @param wildIsletChance chance (0-1) that an empty cell hosts a wild islet * @param wildIsletRadius terrain radius of wild islets (0 = none) * @@ -29,10 +30,8 @@ */ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, - int spawnIsletRadius, double wildIsletChance, int wildIsletRadius) { + IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius) { - /** Default spawn islet radius in blocks. */ - public static final int DEFAULT_SPAWN_ISLET_RADIUS = 48; /** Default wild islet chance / radius. */ public static final double DEFAULT_WILD_CHANCE = 0.3; public static final int DEFAULT_WILD_RADIUS = 70; @@ -44,22 +43,22 @@ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int } /** - * Convenience constructor with default islet parameters. + * Convenience constructor with default wild-islet parameters. */ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, - int spawnIsletRadius) { + IslandType spawnIslandType) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, - typeWeights, spawnIsletRadius, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); + typeWeights, spawnIslandType, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); } /** - * Convenience constructor using the default spawn islet radius. + * Convenience constructor with a seeded spawn island economy. */ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, - typeWeights, DEFAULT_SPAWN_ISLET_RADIUS, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); + typeWeights, null, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); } /** @@ -68,7 +67,7 @@ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLif public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, - defaultTypeWeights(), DEFAULT_SPAWN_ISLET_RADIUS, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); + defaultTypeWeights(), null, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index 6d357d0..8568cbc 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -107,6 +107,15 @@ record Candidate(int cx, int cz, double dist) { .limit(need).map(c -> cellKey(c.cx(), c.cz())).collect(Collectors.toSet()); } + /** + * The spawn island: the trading island reserved at the origin. + * + * @return the spawn island spec + */ + public IslandSpec spawnIsland() { + return islandInCell(0, 0).orElseThrow(); + } + /** * The island hosted by a galaxy cell, if any. Pure function of (seed, cell). * @@ -118,7 +127,18 @@ public Optional islandInCell(int cellX, int cellZ) { return cache.computeIfAbsent(cellKey(cellX, cellZ), k -> computeIsland(cellX, cellZ)); } + /** The name every spawn island is born with (admins may rename it). */ + public static final String SPAWN_NAME = "Spawn"; + private Optional computeIsland(int cellX, int cellZ) { + // The origin cell is reserved for the spawn island: a full trading + // island (dock, plaza, market, warp zone) sitting exactly at 0,0, so + // new sailors start in a working port instead of empty water. + if (cellX == 0 && cellZ == 0) { + IslandType type = config.spawnIslandType() != null ? config.spawnIslandType() : rollType(0, 0); + return Optional.of(new IslandSpec(0, 0, 0, 0, type, SecurityBand.SAFE, rollBiome(0, 0, type), + SPAWN_NAME)); + } boolean starter = starterCells.contains(cellKey(cellX, cellZ)); if (!starter && Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_OCCUPANCY)) >= config.density()) { @@ -130,6 +150,13 @@ private Optional computeIsland(int cellX, int cellZ) { double jz = Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_JITTER_Z)) * 2 - 1; int centerX = (int) Math.round((cellX + 0.5) * size + jx * jitter); int centerZ = (int) Math.round((cellZ + 0.5) * size + jz * jitter); + // Keep clear of the spawn island at the origin + double fromOrigin = Math.hypot(centerX, centerZ); + if (fromOrigin < config.minSeparation()) { + double scale = config.minSeparation() / Math.max(1.0, fromOrigin); + centerX = (int) Math.round(centerX * scale); + centerZ = (int) Math.round(centerZ * scale); + } IslandType type = rollType(cellX, cellZ); SecurityBand band = starter ? SecurityBand.SAFE : rollBand(cellX, cellZ, centerX, centerZ); @@ -233,15 +260,6 @@ public int landLiftAt(int blockX, int blockZ) { best = Math.max(best, mask); } } - // The spawn islet: a small safe island at the origin so players spawn - // and respawn on dry land, never in the seabed - int isletRadius = config.spawnIsletRadius(); - if (isletRadius > 0) { - double d = Math.hypot(blockX, blockZ); - if (d < isletRadius) { - best = Math.max(best, 0.5 * (1 + Math.cos(Math.PI * d / isletRadius))); - } - } // Wild islets: free land between the trading islands Optional wild = wildIsletAt(blockX, blockZ); if (wild.isPresent()) { @@ -391,11 +409,6 @@ public Optional columnPlanAt(int blockX, int blockZ) { * @return biome key, or empty for open ocean */ public Optional biomeKeyAt(int blockX, int blockZ) { - // Spawn islet is gentle plains - if (config.spawnIsletRadius() > 0 - && Math.hypot(blockX, blockZ) < config.spawnIsletRadius()) { - return Optional.of("minecraft:plains"); - } Optional wild = wildIsletAt(blockX, blockZ); if (wild.isPresent()) { return Optional.of(wildIsletBiome(wild.get()[0], wild.get()[1])); diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java index 7600107..00f69cd 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -58,18 +58,32 @@ private void register(IslandSpec spec, ChunkLoadEvent event) { if (!handled.add(key)) { return; } - Location center = new Location(event.getWorld(), spec.centerX() + 0.5, + if (register(spec, event.getWorld()) == null) { + handled.remove(key); + } + } + + /** + * Register a trading island as an unowned BentoBox island: named, flagged + * for its security band, and tagged with its type. Returns the existing + * island if it was registered in a previous session. + * + * @param spec the island + * @param world the overworld + * @return the island, or null if registration failed + */ + public Island register(IslandSpec spec, org.bukkit.World world) { + Location center = new Location(world, spec.centerX() + 0.5, addon.getSettings().getSeaHeight() + 1.0, spec.centerZ() + 0.5); - if (addon.getIslands().getIslandAt(center).isPresent()) { - // Already registered in a previous session - return; + Island existing = addon.getIslands().getIslandAt(center).orElse(null); + if (existing != null) { + return existing; } Island island = addon.getIslands().createIsland(center, null); if (island == null) { addon.logError("Could not register trading island " + spec.name() + " at " + spec.centerX() + "," + spec.centerZ()); - handled.remove(key); - return; + return null; } island.setName(spec.name()); applyBandFlags(island, spec); @@ -80,6 +94,7 @@ private void register(IslandSpec spec, ChunkLoadEvent event) { island.putMetaData(META_BAND, new MetaDataValue(spec.band().name())); addon.log("Registered trading island '" + spec.name() + "' (" + spec.type() + ", " + spec.band() + ") at " + spec.centerX() + "," + spec.centerZ()); + return island; } /** diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 797459e..229ac5f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -29,16 +29,15 @@ galaxy: land-lift: 45 # Distance from spawn per security-band step (Safe -> Policed -> Frontier -> Lawless -> Anarchic). band-radius: 5000 - # Radius of the safe spawn islet generated at the world origin - players spawn - # and respawn there. 0 disables it (not recommended: seabed respawns kill). - spawn-islet-radius: 48 + # Economy of the spawn island - the trading island reserved at the world origin, + # where players spawn and respawn. A working port: dock, plaza, market, warp zone. + # AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN, or RANDOM. + spawn-island-type: FISHING # Chance (0-1) that a galaxy cell WITHOUT a trading island hosts a wild islet - # small unnamed islands, unprotected: mine, farm, build, live. wild-islet-chance: 0.3 # Terrain radius of wild islets. 0 disables them. wild-islet-radius: 70 - # Protection radius of the spawn island (covers the islet plus its shore). - spawn-protection-range: 100 # Relative spawn weight per island type. Higher = more common; 0 disables a type. type-weights: AGRICULTURAL: 10 diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index dd7f83b..9f8be51 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -214,18 +214,35 @@ void testTypeWeightOverrides() { } @Test - void testSpawnIslet() { + void testSpawnIslandAtOrigin() { + // The origin cell is reserved: a full SAFE trading island at exactly 0,0 GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); - // Full lift at the origin even with an empty galaxy, plains biome + IslandSpec spawn = engine.spawnIsland(); + assertEquals(0, spawn.centerX()); + assertEquals(0, spawn.centerZ()); + assertEquals(SecurityBand.SAFE, spawn.band()); + assertEquals(GalaxyEngine.SPAWN_NAME, spawn.name()); + // It has land and a dock like any trading island assertEquals(45, engine.landLiftAt(0, 0)); - assertEquals(java.util.Optional.of("minecraft:plains"), engine.biomeKeyAt(0, 0)); - // Gone past the islet radius (default 48) - assertEquals(0, engine.landLiftAt(60, 0)); - assertTrue(engine.biomeKeyAt(60, 0).isEmpty()); - // Radius 0 disables it - GalaxyEngine off = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 5, 5000, 70, - GalaxyConfig.defaultTypeWeights(), 0)); - assertEquals(0, off.landLiftAt(0, 0)); + assertEquals(spawn, engine.islandAt(0, 0).orElseThrow()); + assertTrue(engine.dockPlan(spawn).dockEnd() > 0); + // Its economy is configurable + GalaxyEngine industrial = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 5, 5000, 70, + GalaxyConfig.defaultTypeWeights(), IslandType.INDUSTRIAL)); + assertEquals(IslandType.INDUSTRIAL, industrial.spawnIsland().type()); + } + + @Test + void testNothingCrowdsTheSpawnIsland() { + // Every other island keeps min separation from the origin + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + for (IslandSpec spec : islands(engine, 6)) { + if (spec.cellX() == 0 && spec.cellZ() == 0) { + continue; + } + double distance = Math.hypot(spec.centerX(), spec.centerZ()); + assertTrue(distance >= 2500, spec.name() + " crowds spawn at " + distance); + } } @Test diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 95aab65..98372a7 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -64,7 +64,7 @@ public void setUp() throws Exception { // Default: an empty galaxy (density 0, no starter islands, no spawn islet) - pure ocean when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) .thenReturn(new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70, - GalaxyConfig.defaultTypeWeights(), 0))); + GalaxyConfig.defaultTypeWeights(), null))); } private WorldInfo worldInfo(Environment env, long seed) { @@ -117,8 +117,9 @@ private RecordingChunkData generate(ChunkGeneratorWorld gen, Environment env, lo @Test void testOceanShape() { + // Far from the origin: the spawn island occupies 0,0 ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); - RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 0, 0); + RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 40, 40); for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { // Bedrock at the bottom @@ -243,8 +244,8 @@ void testPlazaAndDockTerraform() { } @Test - void testSpawnIsletRisesAtOrigin() { - // Even with an empty galaxy, the spawn islet makes dry land at 0,0 + void testSpawnIslandRisesAtOrigin() { + // Even with an empty galaxy, the reserved spawn island makes land at 0,0 when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) .thenReturn(new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70))); ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); @@ -253,7 +254,7 @@ void testSpawnIsletRisesAtOrigin() { for (int y = settings.getSeaHeight() + 1; y < settings.getSeaHeight() + 50 && !landAboveSea; y++) { landAboveSea = r.get(0, y, 0) == Material.GRASS_BLOCK; } - assertTrue(landAboveSea, "Spawn islet should rise above the sea at the origin"); + assertTrue(landAboveSea, "The spawn island should rise above the sea at the origin"); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java index 36f4a48..37fad4d 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java @@ -44,9 +44,9 @@ public void setUp() throws Exception { settings = new Settings(); when(addon.getSettings()).thenReturn(settings); emptyGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70, - GalaxyConfig.defaultTypeWeights(), 0)); + GalaxyConfig.defaultTypeWeights(), null)); denseGalaxy = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70, - GalaxyConfig.defaultTypeWeights(), 0)); + GalaxyConfig.defaultTypeWeights(), null)); when(addon.getGalaxyEngine(anyLong())).thenReturn(emptyGalaxy); provider = new TradeWindsBiomeProvider(addon); } @@ -60,10 +60,12 @@ private WorldInfo worldInfo(Environment env) { @Test void testOpenOceanBiomes() { + // Sampled far from the origin - the spawn island sits at 0,0 WorldInfo wi = worldInfo(Environment.NORMAL); - assertEquals(Biome.OCEAN, provider.getBiome(wi, 0, settings.getSeaHeight(), 0)); - assertEquals(Biome.OCEAN, provider.getBiome(wi, 0, 10, 0)); - assertEquals(settings.getDefaultAirBiome(), provider.getBiome(wi, 0, settings.getSeaHeight() + 1, 0)); + int x = 100_000; + assertEquals(Biome.OCEAN, provider.getBiome(wi, x, settings.getSeaHeight(), x)); + assertEquals(Biome.OCEAN, provider.getBiome(wi, x, 10, x)); + assertEquals(settings.getDefaultAirBiome(), provider.getBiome(wi, x, settings.getSeaHeight() + 1, x)); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java index 1db8109..fde2b03 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java @@ -24,7 +24,8 @@ class ChartNavigationTest { @Test void testMarkersPointTheRightWay() { - IslandSpec spec = engine.islandInCell(0, 0).orElseThrow(); + // Not cell 0,0 - that is the spawn island, directly under the player + IslandSpec spec = engine.islandInCell(1, 1).orElseThrow(); List markers = ChartHolograms.markers(List.of(spec), 0, 0, 10.0, 12); assertEquals(1, markers.size()); ChartHolograms.Marker marker = markers.get(0); @@ -62,6 +63,14 @@ void testMarkerCap() { assertEquals(12, ChartHolograms.markers(many, 0, 0, 10.0, 12).size()); } + @Test + void testSpawnIslandIsUnderfoot() { + // The spawn island sits at the origin: zero distance, no bearing + IslandSpec spawn = engine.spawnIsland(); + assertEquals(0, spawn.centerX()); + assertEquals(0, spawn.centerZ()); + } + @Test void testStarChartPixels() { // On-map island: simple scaled offset from center From a9d87a056036aa3d568cc61ae681d3d4c5e72453 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 17:08:50 -0700 Subject: [PATCH 031/112] Outfitter stores go to the pack; audible trade outcomes Fixes a fishing rod purchase being refused for lack of hold space: gear does not stack to 64 and bundles only accept 64-stackables, so with no chest boat the hold could never take it. Outfitter goods (food, fuel, gear) are for using, not reselling - delivered to the inventory and deliberately not customs stamped, which also stops outfitter arbitrage. Expander refusals now name the real requirement (a chest boat). Trade outcomes are now audible, since dialogs cover the chat box: a bright pling on success, a dull anvil thud on every refusal. 140 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 12 ++++ docs/PROGRESS.md | 21 ++++++ .../tradewinds/economy/MarketService.java | 71 ++++++++++++++----- .../tradewinds/economy/TradeDialog.java | 41 ++++++++++- src/main/resources/locales/en-US.yml | 1 + 5 files changed, 125 insertions(+), 21 deletions(-) diff --git a/TESTING.md b/TESTING.md index 2a38db9..8e5778c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -311,6 +311,18 @@ have not visited). The protection flip and stamps work immediately. catalog has no fuel; INDUSTRIAL sells iron sword/shield/armor; farms sell beds; FISHING sells rods; MINING an iron pickaxe; LUXURY golden apples. All priced by the engine (gear prices derive from their recipes). + - ~~BUG: buying a fishing rod was refused for "no hold space" with 8 free~~ + FIXED: outfitter stores now go to your INVENTORY, not the hold, and are NOT + stamped (they are for using, not reselling - which also stops bread arbitrage). + Unstackable gear could never enter a bundle, which was the actual bug. + Retest: buy a rod with no chest boat - it lands in your pack. + - [ ] Non-stacking gear (rod, sword, bed) offers only x1; stackables also offer x16. + - [ ] Buying an expander without a chest boat now says "buy one from the + shipwright first" instead of a vague hold message. +- [ ] **Trade sounds** (the dialog blurs and covers chat, so outcomes are audible): + every successful buy/sell/expander purchase plays a bright pling; every refusal + (cannot afford, no hold space, unstamped goods, expander cap, no chest boat) + plays a dull anvil thud. - [ ] **Wild islets**: row between trading islands through fresh ocean — small unnamed islands (~140 across) in varied vanilla biomes, no dock/market/name announcement. You can break/place/farm/sleep there and in open ocean diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 03ad69a..7c4d8c2 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,27 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Trade feedback sounds (2026-07-31) + +Dialogs blur and cover the chat box, so refusal messages went unseen: every +market outcome now carries audio - BLOCK_NOTE_BLOCK_PLING (bright, pitch 1.6) +on a successful sell/buy/expander purchase, BLOCK_ANVIL_LAND (dull) on every +refusal path (cannot afford, no hold space, unstamped goods, expander cap, +missing chest boat, cancelled TWTradeEvent). + +## Playtest fix: outfitter stores go to the pack (2026-07-31) + +Buying a fishing rod failed with "no hold space" despite free space: gear does +not stack to 64, and `HoldService.addToBundle` only accepts 64-stackables, so +with no chest boat the hold could never take it. Ben's call (right on both +counts): outfitter goods are consumables/equipment - deliver them to the +player INVENTORY and do not customs-stamp them. Unstamped also means the +outfitter shelf cannot be arbitraged between islands, which keeps it a +service rather than a commodity market. `buyToInventory` generalized to an +amount (hulls reuse it), outfitter got its own dialog page offering x1 for +unstackables and x1/x16 for stackables, and the expander failure message now +names the real cause ("buy a chest boat first"). 140 tests green. + ## Stage 5b — Spawn is a real trading island (2026-07-31) Ben: make spawn a proper named island with dock, plaza and a config-picked diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index f1cc282..d53bc20 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -4,6 +4,7 @@ import org.bukkit.Material; import org.bukkit.NamespacedKey; +import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; @@ -84,34 +85,43 @@ public java.util.List shipwrightCatalog() { } /** - * Buy an item delivered to the player's inventory rather than the hold - - * used for hulls only. Stamped like any purchase. + * Buy stores delivered to the player's INVENTORY, not the hold: outfitter + * supplies (food, gear, beds, rods) and hulls. These are for using, not + * for resale, so they are deliberately NOT customs stamped - which also + * stops the outfitter's shelf becoming an arbitrage route. Anything that + * will not fit is dropped at the player's feet. * - * @return true if bought + * @return how many were bought */ - public boolean buyToInventory(Player player, IslandSpec spec, Material material) { + public int buyToInventory(Player player, IslandSpec spec, Material material, int amount) { Optional unitPrice = playerBuysAt(spec, material); Optional vault = addon.getPlugin().getVault(); - if (unitPrice.isEmpty() || vault.isEmpty()) { - return false; + if (unitPrice.isEmpty() || vault.isEmpty() || amount <= 0) { + return 0; } User user = User.getInstance(player); - if (!vault.get().has(user, unitPrice.get())) { + double balance = vault.get().getBalance(user); + int affordable = (int) Math.min(amount, Math.floor(balance / unitPrice.get())); + if (affordable <= 0) { user.sendMessage("tradewinds.trade.cannot-afford"); - return false; + thud(player); + return 0; } - TWTradeEvent event = new TWTradeEvent(player, spec, material, 1, unitPrice.get(), false); + TWTradeEvent event = new TWTradeEvent(player, spec, material, affordable, unitPrice.get(), false); org.bukkit.Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) { - return false; + thud(player); + return 0; } - vault.get().withdraw(user, unitPrice.get()); - ItemStack item = stamp(new ItemStack(material)); - player.getInventory().addItem(item).values() + double total = PriceModel.round2(affordable * unitPrice.get()); + vault.get().withdraw(user, total); + ItemStack stores = new ItemStack(material, affordable); + player.getInventory().addItem(stores).values() .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); - user.sendMessage("tradewinds.trade.bought", "[amount]", "1", "[material]", pretty(material), "[price]", - String.format("%.2f", unitPrice.get())); - return true; + user.sendMessage("tradewinds.trade.bought", "[amount]", String.valueOf(affordable), "[material]", + pretty(material), "[price]", String.format("%.2f", total)); + chime(player); + return affordable; } /** @@ -211,6 +221,7 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { int count = Math.min(addon.getHoldService().count(player, material, sellableFilter()), amount); if (count <= 0) { User.getInstance(player).sendMessage("tradewinds.trade.not-stamped"); + thud(player); return 0; } TWTradeEvent event = new TWTradeEvent(player, spec, material, count, unitPrice.get(), true); @@ -224,6 +235,7 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), removed); User.getInstance(player).sendMessage("tradewinds.trade.sold", "[amount]", String.valueOf(removed), "[material]", pretty(material), "[price]", String.format("%.2f", total)); + chime(player); return removed; } @@ -245,11 +257,13 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { int affordable = (int) Math.min(amount, Math.floor(balance / unitPrice.get())); if (affordable <= 0) { user.sendMessage("tradewinds.trade.cannot-afford"); + thud(player); return 0; } TWTradeEvent event = new TWTradeEvent(player, spec, material, affordable, unitPrice.get(), false); org.bukkit.Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) { + thud(player); return 0; } // Limit by hold space: add first, pay for what fit. Bought goods are @@ -257,6 +271,7 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { int added = addon.getHoldService().add(player, stamp(new ItemStack(material, affordable))); if (added <= 0) { user.sendMessage("tradewinds.trade.no-hold-space"); + thud(player); return 0; } double total = PriceModel.round2(added * unitPrice.get()); @@ -264,6 +279,7 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), -added); user.sendMessage("tradewinds.trade.bought", "[amount]", String.valueOf(added), "[material]", pretty(material), "[price]", String.format("%.2f", total)); + chime(player); return added; } @@ -281,17 +297,20 @@ public boolean buyExpander(Player player) { int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); if (owned >= addon.getSettings().getExpanderCap()) { user.sendMessage("tradewinds.trade.expander-cap"); + thud(player); return false; } double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); if (!vault.get().has(user, price)) { user.sendMessage("tradewinds.trade.cannot-afford"); + thud(player); return false; } - // Must go into the hold (the chest boat) - expanders never ride pockets + // An expander IS hold - it can only be stowed in a chest boat int added = addon.getHoldService().add(player, expanderItem()); if (added <= 0) { - user.sendMessage("tradewinds.trade.no-hold-space"); + user.sendMessage("tradewinds.trade.needs-chest-boat"); + thud(player); return false; } vault.get().withdraw(user, price); @@ -299,6 +318,7 @@ public boolean buyExpander(Player player) { data.setExpandersPurchased(owned + 1); addon.getPlayerDataManager().save(player.getUniqueId()); user.sendMessage("tradewinds.trade.expander-bought", "[price]", String.format("%.2f", price)); + chime(player); return true; } @@ -322,4 +342,19 @@ public ItemStack expanderItem() { static String pretty(Material material) { return PriceEngine.prettify(material.name()); } + + /** + * A deal struck: bright coin-on-the-counter chime. Dialogs cover the chat + * box, so the sound carries the outcome. + */ + private void chime(Player player) { + player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 0.8f, 1.6f); + } + + /** + * A deal refused: dull anvil thud. + */ + private void thud(Player player) { + player.playSound(player.getLocation(), Sound.BLOCK_ANVIL_LAND, 0.5f, 1.4f); + } } diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index c9aeed2..e933611 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -57,8 +57,8 @@ public void openMain(Player player, IslandSpec spec) { buttons.add(button("Buy goods", NamedTextColor.AQUA, "Buy this island's trade goods into your hold", () -> openBuy(player, spec, addon.getMarketService().saleCatalog(spec), " - Buying"))); } - buttons.add(button("Outfitter", NamedTextColor.GREEN, "Food, fuel and gear for the sailing life", - () -> openBuy(player, spec, addon.getMarketService().outfitterCatalog(spec), " - Outfitter"))); + buttons.add(button("Outfitter", NamedTextColor.GREEN, "Food, fuel and gear - straight to your pack", + () -> openOutfitter(player, spec))); buttons.add(button("Shipwright", NamedTextColor.GOLD, "Boats, chest boats and cargo expanders", () -> openShipwright(player, spec))); show(player, spec.name() + " Market", @@ -134,6 +134,41 @@ private void buyThenReopen(Player player, IslandSpec spec, Material material, in openBuy(player, spec, catalog, titleSuffix); } + /** + * The outfitter's shelf: stores for living, bought into the player's pack + * rather than the hold - a fishing rod is not cargo, and gear that will + * not stack could never fit a bundle anyway. + */ + public void openOutfitter(Player player, IslandSpec spec) { + List buttons = new ArrayList<>(); + for (Material material : addon.getMarketService().outfitterCatalog(spec)) { + addon.getMarketService().playerBuysAt(spec, material).ifPresent(unit -> { + String name = MarketService.pretty(material); + String each = String.format("$%.2f each - carried in your pack, not the hold", unit); + buttons.add(button(String.format("%s x1 - $%.2f", name, unit), NamedTextColor.GREEN, each, + () -> outfitThenReopen(player, spec, material, 1))); + if (new org.bukkit.inventory.ItemStack(material).getMaxStackSize() > 1) { + buttons.add(button(String.format("x%d - $%.2f", MID_BATCH, unit * MID_BATCH), + NamedTextColor.GREEN, each, + () -> outfitThenReopen(player, spec, material, MID_BATCH))); + } + }); + } + if (buttons.isEmpty()) { + User.getInstance(player).sendMessage("tradewinds.trade.nothing-for-sale"); + openMain(player, spec); + return; + } + show(player, spec.name() + " - Outfitter", + List.of("Stores for the sailing life - food, fuel and gear", statusLine(player)), buttons, + backButton(player, spec), 2); + } + + private void outfitThenReopen(Player player, IslandSpec spec, Material material, int amount) { + addon.getMarketService().buyToInventory(player, spec, material, amount); + openOutfitter(player, spec); + } + /** * The shipwright's slipway: hulls to your inventory, expanders to your * hold. The cargo progression lives here: boat -> chest boat -> expanders. @@ -145,7 +180,7 @@ public void openShipwright(Player player, IslandSpec spec) { String.format("%s - $%.2f", MarketService.pretty(hull), unit), NamedTextColor.GOLD, "Delivered to your inventory - place it at the dock", () -> { - addon.getMarketService().buyToInventory(player, spec, hull); + addon.getMarketService().buyToInventory(player, spec, hull, 1); openShipwright(player, spec); }))); } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 11341d6..2b375b8 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -66,6 +66,7 @@ tradewinds: bought: "&a Bought [amount] x [material] for $[price]." cannot-afford: "&c You cannot afford that." no-hold-space: "&c No room in your hold - stow cargo in your chest boat, expanders or bundles." + needs-chest-boat: "&c Expanders are stowed in a chest boat - buy one from the shipwright first." expander-bought: "&6 Cargo expander purchased for $[price] - stowed in your hold." expander-cap: "&c Your boat cannot take any more expanders." interstice: From ef962013c67585d7d3b1df79ab59203136b0d1dd Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 17:15:45 -0700 Subject: [PATCH 032/112] Fix rank flags being silently dropped on islands Island.setFlag in core is a no-op when the flag key is absent from the island's map, and our islands are created with an empty map - so BOAT, CRAFTING, HURT_MONSTERS, HURT_VILLAGERS, ITEM_DROP and ITEM_PICKUP were all discarded, leaving visitors on the default MEMBER rank (no boats at spawn). Rank setting now goes through setRanks(), which merges into the flag map and calls setFlags. Islands from earlier builds are also adopted properly on registration (name and band flags), and spawn adoption repairs the protection range and range left by the old small-island bootstrap. 141 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 9 +++++ docs/PROGRESS.md | 26 ++++++++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 12 +++++-- .../generator/GalaxyIslandRegistrar.java | 35 ++++++++++++++++--- .../generator/GalaxyIslandRegistrarTest.java | 23 ++++++++++-- 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/TESTING.md b/TESTING.md index 8e5778c..1c1c1c5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -422,3 +422,12 @@ database (or use `/twadmin delete` on it), then restart. - [ ] Admin control works as normal BentoBox: `/twadmin setname`, `/twadmin setspawnpoint`, `/twadmin settings` on the spawn island. - [ ] No other island is closer than 2500 blocks to spawn (nothing crowds the port). + +### Stage 5c — rank-flag application (core setFlag pitfall) +- [ ] Non-op at spawn: **place and ride a boat**, use a crafting table, hit a monster. + (Rank flags were silently dropped before — see PROGRESS.md.) +- [ ] Non-op anywhere: drop and pick up items on trading islands. +- [ ] `/tw settings` at spawn shows BOAT/CRAFTING/HURT_MONSTERS at Visitor rank. +- [ ] An island registered by an older build (no name, no flags) is adopted on + restart: it gains its name, band flags, and — for spawn — protection 400 / + range 1000. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7c4d8c2..63be1b3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,32 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Bug: rank flags were being silently dropped (2026-07-31) + +"No boat usage at spawn" - and the same cause behind every rank flag we have +ever set. BentoBox core: + + public void setFlag(Flag flag, int value, boolean doSubflags) { + if (flags.containsKey(flag.getID()) && flags.get(flag.getID()) != value) { + +`setFlag` is a NO-OP when the flag is not already a key in the island's map - +and islands created via `IslandsManager.createIsland` start with an empty map +(the live spawn island's JSON had `"flags": {}`). So BOAT, CRAFTING, +HURT_MONSTERS, HURT_VILLAGERS, ITEM_DROP and ITEM_PICKUP were all discarded, +leaving each flag at its default rank (MEMBER for BOAT) - i.e. visitors +locked out. `setSettingsFlag` uses put() directly, which is why PvP and +monster-spawn settings always worked and masked the problem. +Fix on our side: `GalaxyIslandRegistrar.setRanks(island, Map)` +copies the flag map, writes the ranks and calls `setFlags` (which replaces the +map and marks the island changed). All rank setting now goes through it. +Also: `register()` used to return a pre-existing island untouched, so islands +from earlier builds never got names or flags - it now adopts them (name if +blank, band flags always), and spawn adoption additionally fixes protection +(400) and range (1000) left over from the old small-island bootstrap. +NOTE FOR CORE: `Island.setFlag` arguably should put unconditionally, or +`createIsland` should call `setFlagsDefaults()`. Worth a BentoBox fix. +141 tests green. + ## Trade feedback sounds (2026-07-31) Dialogs blur and cover the chat box, so refusal messages went unseen: every diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index fdf1f22..14cd276 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -393,15 +393,21 @@ private void bootstrapSpawnIsland() { } if (!spawn.isSpawn()) { spawn.setSpawnPoint(Environment.NORMAL, plaza); + // Adopting an island from an older build: give it the full trading + // island geometry it should have had + spawn.setProtectionRange(getSettings().getIslandProtectionRange()); + spawn.setRange(getSettings().getIslandDistance()); + spawn.setName(spec.name()); getIslands().setSpawn(spawn); log("Designated " + spec.name() + " (" + spec.type() + ") as the spawn island"); } // Spawn-port allowances, re-asserted every enable: it is a harbor, so // boats, self-defense and workbenches are everyone's right, and // nothing hostile spawns or explodes here - spawn.setFlag(Flags.BOAT, 0); - spawn.setFlag(Flags.HURT_MONSTERS, 0); - spawn.setFlag(Flags.CRAFTING, 0); + GalaxyIslandRegistrar.setRanks(spawn, java.util.Map.of( + Flags.BOAT, 0, + Flags.HURT_MONSTERS, 0, + Flags.CRAFTING, 0)); spawn.setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, false); spawn.setSettingsFlag(Flags.TNT_DAMAGE, false); spawn.setSettingsFlag(Flags.BLOCK_EXPLODE_DAMAGE, false); diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java index 00f69cd..bf5b17a 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -77,6 +77,12 @@ public Island register(IslandSpec spec, org.bukkit.World world) { addon.getSettings().getSeaHeight() + 1.0, spec.centerZ() + 0.5); Island existing = addon.getIslands().getIslandAt(center).orElse(null); if (existing != null) { + // Adopt an island from an earlier session: it may predate these + // rules (or a config change), so re-apply name and flags + if (existing.getName() == null || existing.getName().isBlank()) { + existing.setName(spec.name()); + } + applyBandFlags(existing, spec); return existing; } Island island = addon.getIslands().createIsland(center, null); @@ -109,10 +115,29 @@ public void applyBandFlags(Island island, IslandSpec spec) { addon.getSettings().getBandPvp().getOrDefault(band, spec.band().isPvp())); island.setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, addon.getSettings().getBandMonsterSpawn().getOrDefault(band, true)); - island.setFlag(Flags.HURT_VILLAGERS, addon.getSettings().getBandHurtVillagersRank().getOrDefault(band, 0)); - // Everyone may drop and pick up items on trading islands - trade, - // jettisoned cargo, and plain convenience all depend on it - island.setFlag(Flags.ITEM_DROP, 0); - island.setFlag(Flags.ITEM_PICKUP, 0); + setRanks(island, java.util.Map.of( + Flags.HURT_VILLAGERS, addon.getSettings().getBandHurtVillagersRank().getOrDefault(band, 0), + // Everyone may drop and pick up items on trading islands - trade, + // jettisoned cargo, and plain convenience all depend on it + Flags.ITEM_DROP, 0, + Flags.ITEM_PICKUP, 0)); + } + + /** + * Set protection-flag ranks on an island. + *

+ * NOTE: {@code Island.setFlag} silently does nothing when the flag is not + * already present in the island's flag map, and islands we create start + * with an empty map - so every rank we set that way was being discarded. + * Writing through the map and calling {@code setFlags} is the reliable + * route. + * + * @param island the island + * @param ranks flag to minimum rank + */ + public static void setRanks(Island island, java.util.Map ranks) { + java.util.Map flags = new HashMap<>(island.getFlags()); + ranks.forEach((flag, rank) -> flags.put(flag.getID(), rank)); + island.setFlags(flags); } } diff --git a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java index b5f131e..35b4ba8 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.lists.Flags; import world.bentobox.tradewinds.CommonTestSetup; import world.bentobox.tradewinds.Settings; @@ -72,6 +73,7 @@ void testRegistersIslandOnCenterChunkLoad() { when(im.getIslandAt(any())).thenReturn(Optional.empty()); when(im.createIsland(any(), isNull())).thenReturn(island); when(island.getMetaData()).thenReturn(Optional.of(new HashMap<>())); + when(island.getFlags()).thenReturn(new HashMap<>()); registrar.onChunkLoad(chunkLoad(spec.centerX() >> 4, spec.centerZ() >> 4)); @@ -86,8 +88,25 @@ void testRegistersIslandOnCenterChunkLoad() { settings.getBandPvp().get(spec.band().name())); verify(island).setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, settings.getBandMonsterSpawn().get(spec.band().name())); - verify(island).setFlag(Flags.HURT_VILLAGERS, - settings.getBandHurtVillagersRank().get(spec.band().name())); + // Rank flags go through setFlags: core's setFlag ignores absent keys + ArgumentCaptor> flags = ArgumentCaptor.forClass(java.util.Map.class); + verify(island).setFlags(flags.capture()); + assertEquals(settings.getBandHurtVillagersRank().get(spec.band().name()), + flags.getValue().get(Flags.HURT_VILLAGERS.getID())); + assertEquals(0, flags.getValue().get(Flags.ITEM_DROP.getID())); + assertEquals(0, flags.getValue().get(Flags.ITEM_PICKUP.getID())); + } + + @Test + void testRankFlagsSurviveAnEmptyFlagMap() { + // The bug: islands are created with an empty flag map, and core's + // Island.setFlag silently drops writes for keys that are not present + Island fresh = mock(Island.class); + when(fresh.getFlags()).thenReturn(new HashMap<>()); + GalaxyIslandRegistrar.setRanks(fresh, java.util.Map.of(Flags.BOAT, 0)); + ArgumentCaptor> flags = ArgumentCaptor.forClass(java.util.Map.class); + verify(fresh).setFlags(flags.capture()); + assertEquals(0, flags.getValue().get(Flags.BOAT.getID())); } @Test From 5f6624ecff5cd1450bac31e36f3d6506c54e4337 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 17:24:31 -0700 Subject: [PATCH 033/112] Bootstrap the spawn island after islands load, and set flags last Boats stayed denied at spawn because bootstrapSpawnIsland ran in onEnable, while core loads islands from the database afterwards: the island cache was empty, so every startup created a fresh island and the database load then shadowed the changes ("spawn": false, no BOAT/CRAFTING/HURT_MONSTERS in the stored flags). It now runs from allLoaded(), which core calls after the load. Island.setSpawn(true) also calls setFlagsDefaults(), which wipes the flag map, so band flags and harbor allowances are now applied after designating spawn rather than before. 141 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 6 ++++- docs/PROGRESS.md | 23 +++++++++++++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 22 ++++++++++++------ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/TESTING.md b/TESTING.md index 1c1c1c5..9c47db6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -423,7 +423,11 @@ database (or use `/twadmin delete` on it), then restart. setspawnpoint`, `/twadmin settings` on the spawn island. - [ ] No other island is closer than 2500 blocks to spawn (nothing crowds the port). -### Stage 5c — rank-flag application (core setFlag pitfall) +### Stage 5c — spawn island flags (two bugs: load order + setFlag) +- [ ] After restart, the island JSON in `plugins/BentoBox/database/Island/` shows + `"spawn": true`, `"name": "Spawn"`, and flags including BOAT/CRAFTING/ + HURT_MONSTERS at 0. +- [ ] Only ONE island file exists at the origin (no duplicate created per restart). - [ ] Non-op at spawn: **place and ride a boat**, use a crafting table, hit a monster. (Rank flags were silently dropped before — see PROGRESS.md.) - [ ] Non-op anywhere: drop and pick up items on trading islands. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 63be1b3..2694961 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,29 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Bug: spawn island bootstrapped before islands loaded (2026-07-31) + +Boats were STILL denied at spawn after the rank-flag fix. The server log gave +it away: + + 17:19:19 [TradeWinds] Designated Spawn (FISHING) as the spawn island + 17:19:19 [BentoBox] Loading islands from database... + +`bootstrapSpawnIsland` ran in onEnable, but core loads islands from the +database AFTER addon enable (BentoBox.java: islandsManager.load() at ~line +234, addonsManager.allLoaded() at ~276). So the island cache was empty: every +startup created a brand-new island (the grid rejected the duplicates) and the +subsequent database load shadowed our in-memory changes - which is why the +island JSON showed name/protection/range from an earlier run but +`"spawn": false` and no BOAT/CRAFTING/HURT_MONSTERS. +Fix: run the bootstrap from `allLoaded()`, which core calls after the island +load. +Second trap found while fixing it: `Island.setSpawn(true)` calls +`setFlagsDefaults()`, wiping the flag map - so ALL flag work must happen +after designating spawn, not before (band flags were being erased). The +bootstrap now applies band flags and harbor allowances last, and is +idempotent. + ## Bug: rank flags were being silently dropped (2026-07-31) "No boat usage at spawn" - and the same cause behind every rank flag we have diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 14cd276..72a7254 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -248,11 +248,9 @@ public void onEnable() { // Navigation boss bar: island, standing, distance to dock navigationBarTask = new NavigationBarTask(this); navigationBarTask.start(); - // Spawn (and bed-less respawn) is the spawn island's market plaza + // Spawn (and bed-less respawn) is the spawn island's market plaza. + // The island itself is adopted in allLoaded() - see below. registerListener(new world.bentobox.tradewinds.listeners.SpawnRespawnListener(this)); - if (islandWorld != null) { - bootstrapSpawnIsland(); - } } @Override @@ -362,6 +360,13 @@ public void saveWorldSettings() { public void allLoaded() { // Save settings. This will occur after all addons have loaded this.saveWorldSettings(); + // IMPORTANT: BentoBox loads islands from the database AFTER addon + // onEnable, so the island cache is empty there. Adopting the spawn + // island any earlier creates a duplicate island every startup and the + // database load then shadows the changes. + if (islandWorld != null) { + bootstrapSpawnIsland(); + } } public BiomeProvider getBiomeProvider() { @@ -401,9 +406,12 @@ private void bootstrapSpawnIsland() { getIslands().setSpawn(spawn); log("Designated " + spec.name() + " (" + spec.type() + ") as the spawn island"); } - // Spawn-port allowances, re-asserted every enable: it is a harbor, so - // boats, self-defense and workbenches are everyone's right, and - // nothing hostile spawns or explodes here + // Flags LAST: Island.setSpawn() resets the flag map to defaults, so + // anything applied before designating spawn would be wiped + registrar.applyBandFlags(spawn, spec); + // Spawn-port allowances: it is a harbor, so boats, self-defense and + // workbenches are everyone's right, and nothing hostile spawns or + // explodes here GalaxyIslandRegistrar.setRanks(spawn, java.util.Map.of( Flags.BOAT, 0, Flags.HURT_MONSTERS, 0, From 24fdc570fb55aecc593e09fde74a770320292589 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 17:51:23 -0700 Subject: [PATCH 034/112] Grant harbor allowances at every trading island, not just spawn applyBandFlags never set BOAT, so with its MEMBER default rank a visitor could moor at any island but not re-board their own boat after shopping. Every trading island is a port: visitors now get BOAT, HURT_MONSTERS, CRAFTING, DOOR and GATE at visitor rank alongside the existing item drop/pickup. The spawn bootstrap no longer repeats them. Run /twadmin reflag to update islands registered by earlier builds. 141 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 5 ++++- docs/PROGRESS.md | 13 +++++++++++++ .../java/world/bentobox/tradewinds/TradeWinds.java | 11 +++-------- .../tradewinds/generator/GalaxyIslandRegistrar.java | 13 ++++++++++++- .../generator/GalaxyIslandRegistrarTest.java | 7 +++++++ 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/TESTING.md b/TESTING.md index 9c47db6..a9590d8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -423,7 +423,10 @@ database (or use `/twadmin delete` on it), then restart. setspawnpoint`, `/twadmin settings` on the spawn island. - [ ] No other island is closer than 2500 blocks to spawn (nothing crowds the port). -### Stage 5c — spawn island flags (two bugs: load order + setFlag) +### Stage 5c — island flags (three bugs: load order, setFlag, port allowances) +- [ ] As NON-OP at ANY trading island (not just spawn): moor, exit and RE-BOARD your + boat after shopping; hit a hostile mob; use a crafting table; open stall gates. + Run `/twadmin reflag` once to push these onto islands registered earlier. - [ ] After restart, the island JSON in `plugins/BentoBox/database/Island/` shows `"spawn": true`, `"name": "Spawn"`, and flags including BOAT/CRAFTING/ HURT_MONSTERS at 0. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2694961..f17487c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,19 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Bug: no boat use at ANY trading island (2026-07-31) + +Third instance of the same class of bug, and the worst: `applyBandFlags` - +which runs on every trading island - never set BOAT at all; only the spawn +island got that allowance. BOAT defaults to MEMBER rank, so on every island +in the galaxy a visitor could moor but not re-board their own boat after +shopping. Fatal for a boat game, and invisible while testing as op. +The harbor allowances now live in applyBandFlags, so every port grants +visitors BOAT, HURT_MONSTERS, CRAFTING, DOOR and GATE (plus the existing +ITEM_DROP/ITEM_PICKUP); the spawn bootstrap no longer repeats them and just +adds its no-hostiles/no-explosions settings. `/twadmin reflag` pushes them to +islands registered before this build. + ## Bug: spawn island bootstrapped before islands loaded (2026-07-31) Boats were STILL denied at spawn after the rank-flag fix. The server log gave diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 72a7254..d8987c6 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -407,15 +407,10 @@ private void bootstrapSpawnIsland() { log("Designated " + spec.name() + " (" + spec.type() + ") as the spawn island"); } // Flags LAST: Island.setSpawn() resets the flag map to defaults, so - // anything applied before designating spawn would be wiped + // anything applied before designating spawn would be wiped. + // applyBandFlags carries the harbor allowances (boats, self-defense, + // crafting, doors) that every trading island needs. registrar.applyBandFlags(spawn, spec); - // Spawn-port allowances: it is a harbor, so boats, self-defense and - // workbenches are everyone's right, and nothing hostile spawns or - // explodes here - GalaxyIslandRegistrar.setRanks(spawn, java.util.Map.of( - Flags.BOAT, 0, - Flags.HURT_MONSTERS, 0, - Flags.CRAFTING, 0)); spawn.setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, false); spawn.setSettingsFlag(Flags.TNT_DAMAGE, false); spawn.setSettingsFlag(Flags.BLOCK_EXPLODE_DAMAGE, false); diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java index bf5b17a..9d5c1cc 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -120,7 +120,18 @@ public void applyBandFlags(Island island, IslandSpec spec) { // Everyone may drop and pick up items on trading islands - trade, // jettisoned cargo, and plain convenience all depend on it Flags.ITEM_DROP, 0, - Flags.ITEM_PICKUP, 0)); + Flags.ITEM_PICKUP, 0, + // Every trading island is a PORT: visitors must be able to + // moor, board and launch boats, defend themselves from + // whatever wandered in, and use a workbench. Without BOAT at + // visitor rank a sailor cannot get back into their own boat + // after shopping. + Flags.BOAT, 0, + Flags.HURT_MONSTERS, 0, + Flags.CRAFTING, 0, + // Doors and gates around the market stalls + Flags.DOOR, 0, + Flags.GATE, 0)); } /** diff --git a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java index 35b4ba8..90ff678 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java @@ -95,6 +95,13 @@ void testRegistersIslandOnCenterChunkLoad() { flags.getValue().get(Flags.HURT_VILLAGERS.getID())); assertEquals(0, flags.getValue().get(Flags.ITEM_DROP.getID())); assertEquals(0, flags.getValue().get(Flags.ITEM_PICKUP.getID())); + // Every trading island is a port: visitors must be able to use boats, + // or they cannot get back into their own boat after shopping + assertEquals(0, flags.getValue().get(Flags.BOAT.getID())); + assertEquals(0, flags.getValue().get(Flags.HURT_MONSTERS.getID())); + assertEquals(0, flags.getValue().get(Flags.CRAFTING.getID())); + assertEquals(0, flags.getValue().get(Flags.DOOR.getID())); + assertEquals(0, flags.getValue().get(Flags.GATE.getID())); } @Test From 6a171a5a1cd0aaee4df0a9695c9e838a4f4eef99 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 18:06:46 -0700 Subject: [PATCH 035/112] Make the hold carried: pouches and expanders, boat as a bonus A chest boat cannot be filled as an inventory item, and a moored one could not be opened either (Flags.CHEST defaults to MEMBER, so visitors could not open their own boat at a port) - so the "chest boat = bigger hold" step did not work in play. The hold is now what the sailor carries: trading pouches, cargo expanders wherever they are carried, and the chest boat's inventory while riding one. Expanders are delivered to the pack rather than requiring a chest boat, so they can always be opened and filled. Ports grant CHEST and SHULKER_BOX at visitor rank so sailors can open their own cargo at a dock. Loose pocket items remain invisible to the market (principle 1). Spec 4 updated. 142 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 11 +++ TRADEWINDS_SPEC.md | 2 +- docs/PROGRESS.md | 18 ++++ .../tradewinds/economy/MarketService.java | 11 +-- .../generator/GalaxyIslandRegistrar.java | 7 +- .../tradewinds/travel/HoldService.java | 87 ++++++++++++------- .../tradewinds/travel/HoldServiceTest.java | 23 +++++ 7 files changed, 118 insertions(+), 41 deletions(-) diff --git a/TESTING.md b/TESTING.md index a9590d8..de3a483 100644 --- a/TESTING.md +++ b/TESTING.md @@ -438,3 +438,14 @@ database (or use `/twadmin delete` on it), then restart. - [ ] An island registered by an older build (no name, no flags) is adopted on restart: it gains its name, band flags, and — for spawn — protection 400 / range 1000. + +### Stage 5d — the carried hold (playtest fix) +- [ ] Buy a Cargo Expander: it lands in your PACK (no chest boat needed). Right-click + it anywhere to open and fill it - that is your hold. +- [ ] Goods inside a carried expander are sellable, and count as fuel for warping. +- [ ] Hold space shown in the market reflects pouches + expanders (+ chest boat when + riding one). +- [ ] Riding a chest boat still adds its 27 slots to the hold; at a port you can + sneak-right-click your own chest boat to open it (CHEST flag now visitor rank). +- [ ] Loose items in your inventory are still NOT sellable (they must be in a pouch, + an expander, or the boat). diff --git a/TRADEWINDS_SPEC.md b/TRADEWINDS_SPEC.md index 9b52500..b4b6bbb 100644 --- a/TRADEWINDS_SPEC.md +++ b/TRADEWINDS_SPEC.md @@ -89,7 +89,7 @@ Free. The border (Border addon in passable/visual mode, or protection-range visu - **Start kit:** boat + 1 **trading bundle** (a bundle = one stack's worth of capacity, vanilla bundle rules). - **Progression:** up to 3 bundles → chest boat → **cargo expanders**: lore-renamed shulker boxes, purchase-only (no End → no crafting), price ~doubling per unit, configurable cap. Expanders live in the chest boat's inventory; their contents count as hold. -- **Hold definition (canonical):** the player's trading bundles + the chest-boat inventory + expander contents. All trade and fuel transactions resolve against the hold only. +- **Hold definition (canonical, revised 2026-07-31):** the player's trading bundles (pouches) + **cargo expanders wherever they are carried** + the chest-boat inventory while riding one. All trade and fuel transactions resolve against the hold only; loose pocket items never count. The hold is *carried*, not moored: a chest boat sitting as an item, or bobbing at a dock, cannot be filled, so the expanders (openable anywhere) are the backbone of cargo capacity and the chest boat is a bonus while sailing. - **Boat ownership:** owner UUID in boat entity PDC from day one (theft/persistence/bounty questions hang off it). ## 5. Economy diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index f17487c..f4294e1 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,24 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The hold is carried, not moored (2026-07-31) + +Ben bought a chest boat and found the hold still capped at his single pouch: +a chest boat is unfillable as an inventory item, and a moored one could not +be opened either - `Flags.CHEST` (MEMBER by default) guards chest boat +inventories, so visitors could not open their own boat at a port. The whole +"chest boat = bigger hold" step therefore did not work in practice. +Redesigned per Ben: the hold is what the sailor CARRIES - + pouches (bundles, max 3) + cargo expanders wherever carried + the chest + boat's inventory while riding one. +Expanders are now delivered to the pack instead of requiring a chest boat, so +they can always be opened and filled; HoldService gained `expanders(player)` +(pack + boat) and its add/remove/count/freeSpace all route through it, with +expanders preferred for storage. Ports also now grant CHEST and SHULKER_BOX +at visitor rank so sailors can open their own cargo at a dock. +Principle 1 is intact: loose pocket items are still invisible to the market. +Spec 4 updated. 142 tests green. + ## Bug: no boat use at ANY trading island (2026-07-31) Third instance of the same class of bug, and the worst: `applyBandFlags` - diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index d53bc20..9f3f21b 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -306,14 +306,11 @@ public boolean buyExpander(Player player) { thud(player); return false; } - // An expander IS hold - it can only be stowed in a chest boat - int added = addon.getHoldService().add(player, expanderItem()); - if (added <= 0) { - user.sendMessage("tradewinds.trade.needs-chest-boat"); - thud(player); - return false; - } + // An expander IS cargo space - it is carried, so it can always be + // opened and filled (a moored or pocketed chest boat cannot be) vault.get().withdraw(user, price); + player.getInventory().addItem(expanderItem()).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); var data = addon.getPlayerDataManager().get(player.getUniqueId()); data.setExpandersPurchased(owned + 1); addon.getPlayerDataManager().save(player.getUniqueId()); diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java index 9d5c1cc..b37b8c8 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -131,7 +131,12 @@ public void applyBandFlags(Island island, IslandSpec spec) { Flags.CRAFTING, 0, // Doors and gates around the market stalls Flags.DOOR, 0, - Flags.GATE, 0)); + Flags.GATE, 0, + // Sailors must be able to open their OWN cargo at a port: a + // chest boat's inventory is guarded by CHEST, an expander by + // SHULKER_BOX + Flags.CHEST, 0, + Flags.SHULKER_BOX, 0)); } /** diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index 94194c2..8734e84 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -19,12 +19,18 @@ /** * The hold: the ONLY container trade transacts against (spec principle 1). - * It is the chest boat's inventory, the contents of cargo-expander shulker - * boxes stored in that inventory, and up to max-bundles trading bundles in - * the player's own inventory. Pocket items are invisible to the market. + * It is what the sailor carries as cargo - + *

    + *
  • trading bundles (pouches), up to max-bundles,
  • + *
  • cargo expanders (shulker boxes) - wherever they are carried,
  • + *
  • and the chest boat's own inventory while riding one.
  • + *
+ * Loose items in pockets are invisible to the market: goods must be stowed in + * a pouch, an expander or the boat to be cargo. That keeps the fuel/cargo + * tension (principle 1) while leaving the hold always openable - a chest boat + * moored ashore, or sitting as an item in the pack, cannot be filled. *

- * The cargo progression IS this definition: bundle(s) -> chest boat -> - * expanders in the chest boat. + * The cargo progression: pouch -> more pouches -> expanders -> chest boat. * * @author tastybento */ @@ -80,11 +86,11 @@ public int freeSpace(Player player) { int free = 0; if (player.getVehicle() instanceof ChestBoat boat) { free += freeIn(boat.getInventory()); - for (ItemStack stack : boat.getInventory().getContents()) { - if (isExpander(stack) && stack.getItemMeta() instanceof BlockStateMeta meta - && meta.getBlockState() instanceof ShulkerBox box) { - free += freeIn(box.getInventory()); - } + } + for (ItemStack expander : expanders(player)) { + if (expander.getItemMeta() instanceof BlockStateMeta meta + && meta.getBlockState() instanceof ShulkerBox box) { + free += freeIn(box.getInventory()); } } for (ItemStack bundleItem : bundles(player)) { @@ -134,18 +140,22 @@ public int remove(Player player, Material material, int amount, Predicate 0 && player.getVehicle() instanceof ChestBoat boat) { ItemStack toAdd = items.clone(); toAdd.setAmount(remaining); Map leftover = boat.getInventory().addItem(toAdd); remaining = leftover.values().stream().mapToInt(ItemStack::getAmount).sum(); - if (remaining <= 0) { - return items.getAmount(); - } - // Try expanders in the boat - for (ItemStack stack : boat.getInventory().getContents()) { - if (remaining <= 0) { - break; - } - if (isExpander(stack)) { - remaining -= addToShulker(stack, items, remaining); - } - } } // Bundles: only simple stackables, vanilla capacity 64 for (ItemStack bundleItem : bundles(player)) { @@ -219,19 +224,37 @@ private List bundles(Player player) { return result; } + /** + * Every cargo expander the sailor carries: in the pack, or stowed in the + * chest boat they are riding. + */ + public List expanders(Player player) { + List result = new ArrayList<>(); + for (ItemStack stack : player.getInventory().getContents()) { + if (isExpander(stack)) { + result.add(stack); + } + } + if (player.getVehicle() instanceof ChestBoat boat) { + for (ItemStack stack : boat.getInventory().getContents()) { + if (isExpander(stack)) { + result.add(stack); + } + } + } + return result; + } + private void forEachHoldStack(Player player, java.util.function.Consumer consumer) { if (player.getVehicle() instanceof ChestBoat boat) { for (ItemStack stack : boat.getInventory().getContents()) { - if (stack == null || stack.getType().isAir()) { + if (stack == null || stack.getType().isAir() || isExpander(stack)) { continue; } - if (isExpander(stack)) { - shulkerContents(stack).forEach(consumer); - } else { - consumer.accept(stack); - } + consumer.accept(stack); } } + expanders(player).forEach(expander -> shulkerContents(expander).forEach(consumer)); for (ItemStack bundleItem : bundles(player)) { if (bundleItem.getItemMeta() instanceof BundleMeta bundle) { bundle.getItems().stream().filter(i -> i != null && !i.getType().isAir()).forEach(consumer); diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java index 049fb4c..881b7f8 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java @@ -70,6 +70,29 @@ void testNoBoatNoHold() { assertEquals(0, service.remove(mockPlayer, Material.WHEAT, 10)); } + @Test + void testExpandersAreCargoWhereverCarried() { + // A cargo expander in the pack is hold: the sailor need not be sitting + // in a chest boat to have somewhere to put goods + ItemStack expander = mock(ItemStack.class); + when(expander.getType()).thenReturn(Material.SHULKER_BOX); + when(expander.hasItemMeta()).thenReturn(true); + org.bukkit.inventory.meta.ItemMeta meta = mock(org.bukkit.inventory.meta.ItemMeta.class); + org.bukkit.persistence.PersistentDataContainer pdc = + mock(org.bukkit.persistence.PersistentDataContainer.class); + when(pdc.has(org.bukkit.NamespacedKey.fromString("tradewinds:expander"), + org.bukkit.persistence.PersistentDataType.STRING)).thenReturn(true); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(expander.getItemMeta()).thenReturn(meta); + + PlayerInventory pockets = mock(PlayerInventory.class); + when(pockets.getContents()).thenReturn(new ItemStack[] { expander, null }); + when(mockPlayer.getInventory()).thenReturn(pockets); + when(mockPlayer.getVehicle()).thenReturn(null); + + assertEquals(1, service.expanders(mockPlayer).size()); + } + @Test void testRemove() { assertEquals(25, service.remove(mockPlayer, Material.WHEAT, 25)); From 2c2f1c6bbd42f8f7baf483ccabe1f32ec1d1d2c2 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 18:14:06 -0700 Subject: [PATCH 036/112] Shipwright sells trading pouches; cargo expanders are white Pouches complete the carried-hold progression: sold up to the max-bundles cap at economy.pouch-price (250), deliberately worse value per item than an expander so expanders remain the goal. HoldService.pouchCount counts past the cap so the shop can refuse. Expanders are now WHITE_SHULKER_BOX so they cannot be mistaken for a vanilla purple shulker; isExpander accepts any shulker colour carrying the PDC key so expanders bought by earlier builds still work. 143 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 7 ++- docs/PROGRESS.md | 13 +++++ .../world/bentobox/tradewinds/Settings.java | 10 +++- .../tradewinds/economy/MarketService.java | 49 ++++++++++++++++++- .../tradewinds/economy/TradeDialog.java | 16 +++++- .../tradewinds/travel/HoldService.java | 18 ++++++- .../tradewinds/travel/StarterKit.java | 3 +- src/main/resources/config.yml | 5 +- src/main/resources/locales/en-US.yml | 4 +- .../tradewinds/travel/HoldServiceTest.java | 24 +++++++++ 10 files changed, 139 insertions(+), 10 deletions(-) diff --git a/TESTING.md b/TESTING.md index de3a483..f70f638 100644 --- a/TESTING.md +++ b/TESTING.md @@ -439,7 +439,12 @@ database (or use `/twadmin delete` on it), then restart. restart: it gains its name, band flags, and — for spawn — protection 400 / range 1000. -### Stage 5d — the carried hold (playtest fix) +### Stage 5d — the carried hold, pouches and white expanders +- [ ] The Shipwright sells **Trading Pouches** ($250) up to the 3-pouch limit, then + refuses ("cannot carry any more"). A pouch is deliberately poorer value per item + than an expander — the early rung, not the destination. +- [ ] Cargo expanders are **white** shulker boxes named "Cargo Expander" (vanilla + purple ones from earlier builds still work as cargo). - [ ] Buy a Cargo Expander: it lands in your PACK (no chest boat needed). Right-click it anywhere to open and fill it - that is your hold. - [ ] Goods inside a carried expander are sellable, and count as fuel for warping. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index f4294e1..1a5e21b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,19 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Pouches for sale, white expanders (2026-07-31) + +Completing the carried-hold progression: the shipwright now sells Trading +Pouches (economy.pouch-price, 250) up to max-bundles, refusing beyond the cap +(HoldService.pouchCount counts past the cap so the shop can tell). At default +prices a pouch is ~$3.90/item against the expander's ~$2.89/item - the early +rung is deliberately the worse deal, so expanders stay the goal. +Expanders are now WHITE_SHULKER_BOX so they never read as a vanilla purple +shulker; isExpander accepts any *SHULKER_BOX carrying the PDC key, so purple +ones bought by earlier builds keep working. StarterKit reuses +MarketService.pouchItem() so the starting pouch and shop pouches are +identical. 143 tests green. + ## The hold is carried, not moored (2026-07-31) Ben bought a chest boat and found the hold still capped at his single pouch: diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index a89e083..8ba2120 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -344,10 +344,16 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "economy.expander-cap") private int expanderCap = 4; - @ConfigComment("Maximum trading bundles that count as hold space.") + @ConfigComment("Maximum trading pouches (bundles) that count as hold space.") @ConfigEntry(path = "economy.max-bundles") private int maxBundles = 3; + @ConfigComment("Price of a trading pouch from the shipwright. A pouch holds 64 items, so at") + @ConfigComment("the default prices it is deliberately worse value per item than a cargo") + @ConfigComment("expander (27 stacks for 5000) - pouches are the early rung, expanders the goal.") + @ConfigEntry(path = "economy.pouch-price") + private double pouchPrice = 250.0; + @ConfigComment("Give customs-stamped goods an enchantment glint as well as their lore line.") @ConfigEntry(path = "economy.stamp-glint") private boolean stampGlint = false; @@ -2452,6 +2458,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setExpanderCap(int expanderCap) { this.expanderCap = expanderCap; } public int getMaxBundles() { return maxBundles; } public void setMaxBundles(int maxBundles) { this.maxBundles = maxBundles; } + public double getPouchPrice() { return pouchPrice; } + public void setPouchPrice(double pouchPrice) { this.pouchPrice = pouchPrice; } public boolean isStampGlint() { return stampGlint; } public void setStampGlint(boolean stampGlint) { this.stampGlint = stampGlint; } public List getUnstampedSellables() { return unstampedSellables; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 9f3f21b..363bd7e 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -324,7 +324,8 @@ public boolean buyExpander(Player player) { * no End there are no shulker shells to craft one (spec principle 7). */ public ItemStack expanderItem() { - ItemStack item = new ItemStack(Material.SHULKER_BOX); + // White, so it never reads as a vanilla purple shulker box + ItemStack item = new ItemStack(Material.WHITE_SHULKER_BOX); ItemMeta meta = item.getItemMeta(); if (meta != null) { meta.displayName(Component.text("Cargo Expander", NamedTextColor.GOLD)); @@ -336,6 +337,52 @@ public ItemStack expanderItem() { return item; } + /** + * A trading pouch: the first rung of cargo space, and the only hold a new + * sailor has. + */ + public ItemStack pouchItem() { + ItemStack pouch = new ItemStack(Material.BUNDLE); + ItemMeta meta = pouch.getItemMeta(); + if (meta != null) { + meta.displayName(Component.text("Trading Pouch", NamedTextColor.GOLD)); + meta.lore(java.util.List.of(Component.text("Cargo space. Stow goods to sell them.", + NamedTextColor.GRAY))); + pouch.setItemMeta(meta); + } + return pouch; + } + + /** + * Buy a trading pouch, up to the hold's pouch limit. + * + * @return true if bought + */ + public boolean buyPouch(Player player) { + Optional vault = addon.getPlugin().getVault(); + if (vault.isEmpty()) { + return false; + } + User user = User.getInstance(player); + if (addon.getHoldService().pouchCount(player) >= addon.getSettings().getMaxBundles()) { + user.sendMessage("tradewinds.trade.pouch-cap"); + thud(player); + return false; + } + double price = addon.getSettings().getPouchPrice(); + if (!vault.get().has(user, price)) { + user.sendMessage("tradewinds.trade.cannot-afford"); + thud(player); + return false; + } + vault.get().withdraw(user, price); + player.getInventory().addItem(pouchItem()).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + user.sendMessage("tradewinds.trade.pouch-bought", "[price]", String.format("%.2f", price)); + chime(player); + return true; + } + static String pretty(Material material) { return PriceEngine.prettify(material.name()); } diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index e933611..5b0e9c3 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -184,12 +184,24 @@ public void openShipwright(Player player, IslandSpec spec) { openShipwright(player, spec); }))); } + int pouches = addon.getHoldService().pouchCount(player); + int maxPouches = addon.getSettings().getMaxBundles(); + if (pouches < maxPouches) { + buttons.add(button(String.format("Trading Pouch - $%.0f", addon.getSettings().getPouchPrice()), + NamedTextColor.GOLD, + "Holds 64 items (" + pouches + "/" + maxPouches + " carried). Cheap to start with; a " + + "cargo expander is far better value once you can afford one.", + () -> { + addon.getMarketService().buyPouch(player); + openShipwright(player, spec); + })); + } int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); if (owned < addon.getSettings().getExpanderCap()) { double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); buttons.add(button(String.format("Cargo Expander - $%.0f", price), NamedTextColor.GOLD, - "Shulker hold expansion (" + owned + "/" + addon.getSettings().getExpanderCap() - + " owned). Price doubles each time. Stowed into your chest boat.", + "27 stacks of cargo space (" + owned + "/" + addon.getSettings().getExpanderCap() + + " owned). Price doubles each time. Carried in your pack - open it anywhere.", () -> { addon.getMarketService().buyExpander(player); openShipwright(player, spec); diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index 8734e84..d70bc9d 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -200,12 +200,28 @@ public int add(Player player, ItemStack items) { * Is this item a TradeWinds cargo expander (lore-renamed shulker box)? */ public boolean isExpander(ItemStack stack) { - return stack != null && stack.getType() == Material.SHULKER_BOX && stack.hasItemMeta() + // Any shulker box colour: expanders ship white to tell them apart from + // vanilla purple, but older purple ones stay valid + return stack != null && stack.getType().name().endsWith("SHULKER_BOX") && stack.hasItemMeta() && stack.getItemMeta().getPersistentDataContainer().has( org.bukkit.NamespacedKey.fromString("tradewinds:expander"), org.bukkit.persistence.PersistentDataType.STRING); } + /** + * How many trading pouches the player carries, counting past the cap - the + * shipwright needs the true number to refuse selling more. + */ + public int pouchCount(Player player) { + int count = 0; + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getItemMeta() instanceof BundleMeta) { + count += stack.getAmount(); + } + } + return count; + } + /** * The player's trading bundles, capped at the configured maximum - a * fourth bundle is just a bag, not hold space. diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java index 83e2c9f..0715be6 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java @@ -71,10 +71,9 @@ public void onSpawnArrival(Player player) { } private ItemStack tradingBundle() { - ItemStack bundle = new ItemStack(Material.BUNDLE); + ItemStack bundle = addon.getMarketService().pouchItem(); ItemMeta meta = bundle.getItemMeta(); if (meta != null) { - meta.displayName(Component.text("Trading Bundle", NamedTextColor.GOLD)); // A little coal in the bundle: it is hold cargo, so the first // island hop can be a warp instead of a long row int coal = addon.getSettings().getStarterCoal(); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 229ac5f..2e8c9fc 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -161,8 +161,11 @@ economy: # Cargo expanders: first price, doubling each purchase, hard cap per player. expander-base-price: 5000.0 expander-cap: 4 - # Maximum trading bundles that count as hold space. + # Maximum trading pouches (bundles) that count as hold space. max-bundles: 3 + # Price of a trading pouch. A pouch holds 64 items, so at the default prices it is + # deliberately worse value per item than an expander - pouches are the early rung. + pouch-price: 250.0 # Give customs-stamped goods an enchantment glint as well as their lore line. stamp-glint: false # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 2b375b8..536a8eb 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -67,7 +67,9 @@ tradewinds: cannot-afford: "&c You cannot afford that." no-hold-space: "&c No room in your hold - stow cargo in your chest boat, expanders or bundles." needs-chest-boat: "&c Expanders are stowed in a chest boat - buy one from the shipwright first." - expander-bought: "&6 Cargo expander purchased for $[price] - stowed in your hold." + expander-bought: "&6 Cargo expander purchased for $[price] - carried in your pack." + pouch-bought: "&6 Trading pouch purchased for $[price]." + pouch-cap: "&c You cannot carry any more trading pouches." expander-cap: "&c Your boat cannot take any more expanders." interstice: stranded: "&4 The warp collapses! Dark water, and screaming above it." diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java index 881b7f8..258669a 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java @@ -1,6 +1,8 @@ package world.bentobox.tradewinds.travel; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -93,6 +95,28 @@ void testExpandersAreCargoWhereverCarried() { assertEquals(1, service.expanders(mockPlayer).size()); } + @Test + void testExpandersAreRecognisedByAnyShulkerColour() { + // Expanders ship WHITE to differentiate them from vanilla purple, but + // purple ones bought by earlier builds must keep working + for (Material colour : new Material[] { Material.WHITE_SHULKER_BOX, Material.SHULKER_BOX }) { + ItemStack expander = mock(ItemStack.class); + when(expander.getType()).thenReturn(colour); + when(expander.hasItemMeta()).thenReturn(true); + org.bukkit.inventory.meta.ItemMeta meta = mock(org.bukkit.inventory.meta.ItemMeta.class); + org.bukkit.persistence.PersistentDataContainer pdc = + mock(org.bukkit.persistence.PersistentDataContainer.class); + when(pdc.has(org.bukkit.NamespacedKey.fromString("tradewinds:expander"), + org.bukkit.persistence.PersistentDataType.STRING)).thenReturn(true); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(expander.getItemMeta()).thenReturn(meta); + assertTrue(service.isExpander(expander), colour + " should be a valid expander"); + } + // A plain shulker box with no stamp is not cargo space + ItemStack plain = new ItemStack(Material.WHITE_SHULKER_BOX); + assertFalse(service.isExpander(plain)); + } + @Test void testRemove() { assertEquals(25, service.remove(mockPlayer, Material.WHEAT, 25)); From 9e7a4ddecba69c8ff460841fef96391958ac640a Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 18:24:11 -0700 Subject: [PATCH 037/112] Respawn on the plaza, not in the trees at the island centre SpawnRespawnListener still used getHighestBlockYAt(0, 0) from the bare-islet days, which on a real trading island is wooded ground - players respawned in the treetops and had to drop off. It now uses the island's own spawn point (the plaza, and wherever an admin moves it with /twadmin setspawnpoint), falling back to the world spawn. The spawn point sits 3 blocks off the plaza centre so nobody materialises inside the bell. 144 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 8 +++++++ docs/PROGRESS.md | 10 ++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 6 +++-- .../listeners/SpawnRespawnListener.java | 8 ++++--- .../listeners/SpawnRespawnListenerTest.java | 23 +++++++++++++++---- 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/TESTING.md b/TESTING.md index f70f638..8aec524 100644 --- a/TESTING.md +++ b/TESTING.md @@ -454,3 +454,11 @@ database (or use `/twadmin delete` on it), then restart. sneak-right-click your own chest boat to open it (CHEST flag now visitor rank). - [ ] Loose items in your inventory are still NOT sellable (they must be in a pouch, an expander, or the boat). + +### Stage 5e — respawn on the plaza (playtest fix) +- [ ] Die without a bed: you respawn ON the spawn island's market plaza, standing on + flat ground beside the stalls — not in the treetops at the island centre, and + not inside the bell. +- [ ] `/tw spawn` puts you in the same place. +- [ ] After `/twadmin setspawnpoint` somewhere else on the island, respawns and + `/tw spawn` both honour the new point. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 1a5e21b..b71aa10 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,16 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest fix: respawn in the treetops (2026-07-31) + +Ben died and respawned on top of a tree at the island centre. SpawnRespawnListener +was still using `getHighestBlockYAt(0, 0)` from the bare-islet days - the island +CENTRE, which on a real trading island is wooded ground. It now uses +`IslandsManager.getSpawnPoint(world)` (the plaza, and wherever an admin later +moves it with /twadmin setspawnpoint), falling back to the world spawn. +The spawn point itself moved 3 blocks off the plaza centre so players do not +materialise inside the bell that stands there. 144 tests green. + ## Pouches for sale, white expanders (2026-07-31) Completing the carried-hold progression: the shipwright now sells Trading diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index d8987c6..5dd2525 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -385,8 +385,10 @@ private void bootstrapSpawnIsland() { world.bentobox.tradewinds.galaxy.IslandSpec spec = engine.spawnIsland(); // The plaza is deterministic geometry - no chunk needs to be loaded world.bentobox.tradewinds.galaxy.DockPlan plan = engine.dockPlan(spec); - org.bukkit.Location plaza = new org.bukkit.Location(islandWorld, plan.plazaX() + 0.5, - getSettings().getSeaHeight() + GalaxyEngine.PLAZA_RISE + 1.0, plan.plazaZ() + 0.5); + // Three blocks off the plaza centre: clear of the bell that stands + // there, and well inside the stall ring + org.bukkit.Location plaza = new org.bukkit.Location(islandWorld, plan.plazaX() + 3.5, + getSettings().getSeaHeight() + GalaxyEngine.PLAZA_RISE + 1.0, plan.plazaZ() + 3.5); islandWorld.setSpawnLocation(plaza); GalaxyIslandRegistrar registrar = new GalaxyIslandRegistrar(this); diff --git a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java index 1986868..5a0a9bb 100644 --- a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java +++ b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java @@ -38,8 +38,10 @@ public void onRespawn(PlayerRespawnEvent event) { if (overworld == null) { return; } - int top = overworld.getHighestBlockYAt(0, 0); - event.setRespawnLocation(new Location(overworld, 0.5, - Math.max(top + 1, addon.getSettings().getSeaHeight() + 1), 0.5)); + // The spawn island's own spawn point - the market plaza, and wherever + // an admin has since moved it. The island CENTRE is wooded ground and + // must not be used: players were respawning in the treetops. + Location point = addon.getIslands().getSpawnPoint(overworld); + event.setRespawnLocation(point != null ? point : overworld.getSpawnLocation()); } } diff --git a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java index b9e4b33..76db95a 100644 --- a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java +++ b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java @@ -32,19 +32,32 @@ public void setUp() throws Exception { addon = mock(TradeWinds.class); when(addon.getSettings()).thenReturn(new Settings()); when(addon.getOverWorld()).thenReturn(world); - when(world.getHighestBlockYAt(0, 0)).thenReturn(95); + when(addon.getIslands()).thenReturn(im); + // The spawn island's spawn point: the market plaza + when(im.getSpawnPoint(world)).thenReturn(new Location(world, 72.5, 73, 72.5)); when(mockPlayer.getWorld()).thenReturn(world); listener = new SpawnRespawnListener(addon); deathBed = new Location(world, 500, 42, 500); } @Test - void testBedlessRespawnGoesToIslet() { + void testBedlessRespawnGoesToThePlaza() { + // NOT the island centre (wooded - players were respawning in treetops) PlayerRespawnEvent event = new PlayerRespawnEvent(mockPlayer, deathBed, false, false); listener.onRespawn(event); - assertEquals(96, event.getRespawnLocation().getBlockY()); - assertEquals(0, event.getRespawnLocation().getBlockX()); - assertEquals(0, event.getRespawnLocation().getBlockZ()); + assertEquals(73, event.getRespawnLocation().getBlockY()); + assertEquals(72, event.getRespawnLocation().getBlockX()); + assertEquals(72, event.getRespawnLocation().getBlockZ()); + } + + @Test + void testFallsBackToWorldSpawnWithoutASpawnIsland() { + when(im.getSpawnPoint(world)).thenReturn(null); + Location worldSpawn = new Location(world, 10, 72, 10); + when(world.getSpawnLocation()).thenReturn(worldSpawn); + PlayerRespawnEvent event = new PlayerRespawnEvent(mockPlayer, deathBed, false, false); + listener.onRespawn(event); + assertEquals(worldSpawn, event.getRespawnLocation()); } @Test From 970f585f110d4a49cadba41cda4c862f56ee88ca Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 18:32:04 -0700 Subject: [PATCH 038/112] Findable wild islets, openable cargo expanders, sighting range The open ocean was empty: islets shared the trading-island grid (5000-block cells, only where no island stood), averaging ~9000 blocks apart - measured zero islets in a 6000x6000 area around 10000,10000. They now use their own grid (galaxy.wild-islet-grid, 1200) with a clearance check against trading islands: an islet is within ~500-1700 blocks of anywhere. Java Edition cannot open a shulker box from the inventory, so the carried hold could not be filled by hand. ExpanderListener opens an expander on right-click and writes the contents back on close; expanders cannot be nested or placed as blocks. Charting now uses chart.sighting-range (1200, just beyond island waters) so islands are charted as they are sighted. 145 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 12 ++ docs/PROGRESS.md | 20 ++++ .../world/bentobox/tradewinds/Settings.java | 20 +++- .../world/bentobox/tradewinds/TradeWinds.java | 6 +- .../tradewinds/galaxy/GalaxyConfig.java | 23 +++- .../tradewinds/galaxy/GalaxyEngine.java | 22 ++-- .../tradewinds/travel/ChartingListener.java | 4 +- .../tradewinds/travel/ExpanderListener.java | 110 ++++++++++++++++++ src/main/resources/config.yml | 10 +- .../tradewinds/galaxy/GalaxyEngineTest.java | 40 ++++++- 10 files changed, 243 insertions(+), 24 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java diff --git a/TESTING.md b/TESTING.md index 8aec524..fe5a883 100644 --- a/TESTING.md +++ b/TESTING.md @@ -462,3 +462,15 @@ database (or use `/twadmin delete` on it), then restart. - [ ] `/tw spawn` puts you in the same place. - [ ] After `/twadmin setspawnpoint` somewhere else on the island, respawns and `/tw spawn` both honour the new point. + +### Stage 5f — findable islets, openable expanders, charting range +- [ ] Row (or teleport) into open ocean far from any island: wild islets are now + within ~500-1700 blocks of anywhere — you should meet one within a short row. + They keep clear of trading islands. +- [ ] **Right-click a Cargo Expander in hand** to open it — this is the only way + (Java cannot open shulker boxes from the inventory). Put items in, close, and + they are still there when you reopen; they count as hold for trading and fuel. +- [ ] An expander cannot be placed as a block, and cannot be stowed inside another. +- [ ] Rowing within ~1200 blocks of an uncharted island charts it ("Charted !" + on the action bar) and it then appears in /tw chart, the warp dialog and the + star chart. `chart.sighting-range` tunes this. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b71aa10..a5d1393 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,26 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest fixes: empty ocean, unopenable expanders (2026-07-31) + +Ben teleported to 10000,10000 and found nothing at all. Measured: nearest +trading island 7329 blocks, and ZERO wild islets in a 6000x6000 area. Cause: +islets shared the trading-island grid (5000-block cells, and only in cells +with no island), averaging ~9000 blocks apart. They now have their own finer +grid (galaxy.wild-islet-grid, 1200) with a clearance test against trading +islands, giving an islet within ~500-1700 blocks of anywhere (tested). + +Also, and worse: **Java Edition cannot open a shulker box from the +inventory**, so the "carried hold" I had just designed could not be filled or +emptied by hand at all - Ben found this trying to read the lore on stamped +goods. `ExpanderListener` now opens an expander on right-click (27-slot view, +written back into the item on close), refuses nesting, and never lets an +expander be placed as a block (it is cargo, not a chest to be robbed). + +Charting range is now config (`chart.sighting-range`, 1200 - slightly beyond +island waters) so islands chart as they are sighted rather than only when +entering their waters. 145 tests green. + ## Playtest fix: respawn in the treetops (2026-07-31) Ben died and respawned on top of a tree at the island centre. SpawnRespawnListener diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 8ba2120..bc835b7 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -102,11 +102,17 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.spawn-island-type", needsReset = true) private String spawnIslandType = "FISHING"; - @ConfigComment("Chance (0-1) that a galaxy cell WITHOUT a trading island hosts a wild islet -") - @ConfigComment("small unnamed islands, unprotected: mine, farm, build, live. Minecraft-stuff land.") + @ConfigComment("Chance (0-1) that a wild-islet grid cell hosts one - small unnamed islands,") + @ConfigComment("unprotected: mine, farm, build, live. Minecraft-stuff land.") @ConfigEntry(path = "galaxy.wild-islet-chance", needsReset = true) private double wildIsletChance = 0.3; + @ConfigComment("Grid size in blocks for wild islets. Much finer than the trading island grid,") + @ConfigComment("so the open sea is dotted with land: at 1200 with chance 0.3 there is usually") + @ConfigComment("an islet within a couple of thousand blocks of anywhere.") + @ConfigEntry(path = "galaxy.wild-islet-grid", needsReset = true) + private int wildIsletGrid = 1200; + @ConfigComment("Terrain radius of wild islets. 0 disables them.") @ConfigEntry(path = "galaxy.wild-islet-radius", needsReset = true) private int wildIsletRadius = 70; @@ -196,6 +202,12 @@ private static Map defaultBandHurtVillagers() { @ConfigEntry(path = "chart.hologram-max") private int chartHologramMax = 12; + @ConfigComment("How close a sailor must pass an island to chart it, in blocks. Slightly beyond") + @ConfigComment("island waters (distance-between-islands) so islands are charted as they are") + @ConfigComment("sighted. Only charted islands can be warped to.") + @ConfigEntry(path = "chart.sighting-range") + private int chartSightingRange = 1200; + @ConfigComment("Show the hologram chart automatically whenever a player boards a boat -") @ConfigComment("new players see where to go the moment they set sail, untold.") @ConfigEntry(path = "chart.show-on-boarding") @@ -2380,6 +2392,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWildIsletChance(double wildIsletChance) { this.wildIsletChance = wildIsletChance; } public int getWildIsletRadius() { return wildIsletRadius; } public void setWildIsletRadius(int wildIsletRadius) { this.wildIsletRadius = wildIsletRadius; } + public int getWildIsletGrid() { return wildIsletGrid; } + public void setWildIsletGrid(int wildIsletGrid) { this.wildIsletGrid = wildIsletGrid; } public Map getTypeWeights() { return typeWeights; } public void setTypeWeights(Map typeWeights) { this.typeWeights = typeWeights; } public double getFuelPerBlock() { return fuelPerBlock; } @@ -2424,6 +2438,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setChartHologramSeconds(int chartHologramSeconds) { this.chartHologramSeconds = chartHologramSeconds; } public int getChartHologramMax() { return chartHologramMax; } public void setChartHologramMax(int chartHologramMax) { this.chartHologramMax = chartHologramMax; } + public int getChartSightingRange() { return chartSightingRange; } + public void setChartSightingRange(int chartSightingRange) { this.chartSightingRange = chartSightingRange; } public boolean isChartOnBoarding() { return chartOnBoarding; } public void setChartOnBoarding(boolean chartOnBoarding) { this.chartOnBoarding = chartOnBoarding; } public int getStarChartBlocksPerPixel() { return starChartBlocksPerPixel; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 5dd2525..d6d43bd 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -48,6 +48,7 @@ import world.bentobox.tradewinds.travel.BorderPromptListener; import world.bentobox.tradewinds.travel.ChartHolograms; import world.bentobox.tradewinds.travel.ChartingListener; +import world.bentobox.tradewinds.travel.ExpanderListener; import world.bentobox.tradewinds.travel.FuelService; import world.bentobox.tradewinds.travel.IntersticeService; import world.bentobox.tradewinds.travel.HoldService; @@ -235,6 +236,9 @@ public void onEnable() { registerListener(new BorderPromptListener(this)); // Teleporting while boated brings the boat (and cargo) along registerListener(new BoatPickupListener(this)); + // Cargo expanders open in the hand (Java cannot open shulkers in an + // inventory, and a carried hold has to be openable) + registerListener(new ExpanderListener(this)); // Risk at sea: the interstice for warpers, encounters for rowers intersticeService = new IntersticeService(this); intersticeService.start(); @@ -496,7 +500,7 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { galaxyEngine = new GalaxyEngine(new GalaxyConfig(seed, s.getGalaxyMinSeparation(), s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight(), typeWeights(), - spawnIslandType(), s.getWildIsletChance(), s.getWildIsletRadius())); + spawnIslandType(), s.getWildIsletChance(), s.getWildIsletRadius(), s.getWildIsletGrid())); log("TradeWinds galaxy seed: " + seed); } return galaxyEngine; diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java index febaec7..a3e5955 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -23,18 +23,21 @@ * total falls back to the built-in defaults * @param spawnIslandType economy of the trading island reserved at the origin * (null = seeded roll like any other island) - * @param wildIsletChance chance (0-1) that an empty cell hosts a wild islet + * @param wildIsletChance chance (0-1) that a wild-islet grid cell hosts one * @param wildIsletRadius terrain radius of wild islets (0 = none) + * @param wildIsletGrid grid size in blocks for wild islets - much finer than the + * trading island grid, so the open sea is dotted with land to land on * * @author tastybento */ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, - IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius) { + IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius, int wildIsletGrid) { - /** Default wild islet chance / radius. */ + /** Default wild islet chance / radius / grid. */ public static final double DEFAULT_WILD_CHANCE = 0.3; public static final int DEFAULT_WILD_RADIUS = 70; + public static final int DEFAULT_WILD_GRID = 1200; public GalaxyConfig { if (typeWeights == null || typeWeights.values().stream().mapToInt(w -> Math.max(0, w)).sum() <= 0) { @@ -49,7 +52,17 @@ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLif int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, IslandType spawnIslandType) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, - typeWeights, spawnIslandType, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); + typeWeights, spawnIslandType, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS, DEFAULT_WILD_GRID); + } + + /** + * Convenience constructor with the default wild islet grid. + */ + public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, + int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, + IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius) { + this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, + typeWeights, spawnIslandType, wildIsletChance, wildIsletRadius, DEFAULT_WILD_GRID); } /** @@ -67,7 +80,7 @@ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLif public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, - defaultTypeWeights(), null, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS); + defaultTypeWeights(), null, DEFAULT_WILD_CHANCE, DEFAULT_WILD_RADIUS, DEFAULT_WILD_GRID); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index 8568cbc..03a4f4d 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -279,18 +279,26 @@ public int landLiftAt(int blockX, int blockZ) { * @return {centerX, centerZ} or empty */ public Optional wildIsletInCell(int cellX, int cellZ) { - if (config.wildIsletChance() <= 0 || islandInCell(cellX, cellZ).isPresent()) { + if (config.wildIsletChance() <= 0 || config.wildIsletRadius() <= 0) { return Optional.empty(); } if (Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD)) >= config.wildIsletChance()) { return Optional.empty(); } - int size = config.cellSize(); - int jitter = config.jitter(); + int size = config.wildIsletGrid(); + int jitter = size / 4; double jx = Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_X)) * 2 - 1; double jz = Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_Z)) * 2 - 1; - return Optional.of(new int[] { (int) Math.round((cellX + 0.5) * size + jx * jitter), - (int) Math.round((cellZ + 0.5) * size + jz * jitter) }); + int x = (int) Math.round((cellX + 0.5) * size + jx * jitter); + int z = (int) Math.round((cellZ + 0.5) * size + jz * jitter); + // Never crowd a trading island: its terrain, its dock and a margin + int clearance = config.terrainRadius() + config.wildIsletRadius() + 80; + for (IslandSpec spec : islandsNear(x, z, clearance)) { + if (spec.distanceSquared(x, z) < (long) clearance * clearance) { + return Optional.empty(); + } + } + return Optional.of(new int[] { x, z }); } /** @@ -305,7 +313,7 @@ public Optional wildIsletAt(int blockX, int blockZ) { if (radius <= 0) { return Optional.empty(); } - int size = config.cellSize(); + int size = config.wildIsletGrid(); int cellX = Math.floorDiv(blockX, size); int cellZ = Math.floorDiv(blockZ, size); for (int cx = cellX - 1; cx <= cellX + 1; cx++) { @@ -327,7 +335,7 @@ public Optional wildIsletAt(int blockX, int blockZ) { * A wild islet's whole-island biome (seeded from its cell). */ public String wildIsletBiome(int centerX, int centerZ) { - int size = config.cellSize(); + int size = config.wildIsletGrid(); long hash = Hashing.cellHash(config.seed(), Math.floorDiv(centerX, size), Math.floorDiv(centerZ, size), SALT_WILD_BIOME); return WILD_BIOMES.get((int) Math.floorMod(hash, WILD_BIOMES.size())); diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java index 1ca2d4a..409fe67 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java @@ -71,7 +71,9 @@ public void onMove(PlayerMoveEvent event) { } int x = event.getTo().getBlockX(); int z = event.getTo().getBlockZ(); - int range = addon.getSettings().getIslandDistance(); + // Charting range is the sighting range, a little wider than island + // waters, so passing an island puts it on the chart + int range = addon.getSettings().getChartSightingRange(); TWPlayerData data = addon.getPlayerDataManager().get(player.getUniqueId()); addon.getGalaxyEngine(player.getWorld().getSeed()).islandsNear(x, z, range).stream() .filter(spec -> spec.distanceSquared(x, z) <= (long) range * range) diff --git a/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java b/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java new file mode 100644 index 0000000..68f5068 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java @@ -0,0 +1,110 @@ +package world.bentobox.tradewinds.travel; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Sound; +import org.bukkit.block.ShulkerBox; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BlockStateMeta; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Opens a cargo expander in the hand. Java Edition cannot open a shulker box + * from the inventory, so without this the carried hold could never be filled + * or emptied by hand - the whole point of carrying it. Right-click opens the + * expander's contents; closing writes them back into the item. + *

+ * Expanders are never placed as blocks: they are cargo, and a placed one + * would be a chest anyone could rob. + * + * @author tastybento + */ +public class ExpanderListener implements Listener { + + private final TradeWinds addon; + /** Player -> hotbar slot of the expander they have open. */ + private final Map open = new HashMap<>(); + + public ExpanderListener(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onRightClick(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND + || (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) + || !addon.getHoldService().isExpander(event.getItem())) { + return; + } + // Never place it as a block - it is cargo + event.setCancelled(true); + Player player = event.getPlayer(); + ItemStack expander = event.getItem(); + if (!(expander.getItemMeta() instanceof BlockStateMeta meta) + || !(meta.getBlockState() instanceof ShulkerBox box)) { + return; + } + Inventory view = Bukkit.createInventory(null, 27, + Component.text("Cargo Expander", NamedTextColor.DARK_AQUA)); + view.setContents(box.getInventory().getContents()); + open.put(player.getUniqueId(), player.getInventory().getHeldItemSlot()); + player.openInventory(view); + player.playSound(player.getLocation(), Sound.BLOCK_SHULKER_BOX_OPEN, 0.6f, 1.2f); + } + + /** + * No nesting: an expander cannot be stowed inside another. + */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onClick(InventoryClickEvent event) { + if (!open.containsKey(event.getWhoClicked().getUniqueId())) { + return; + } + ItemStack moving = event.getCurrentItem() != null && event.getClick().isShiftClick() ? event.getCurrentItem() + : event.getCursor(); + if (addon.getHoldService().isExpander(moving)) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onClose(InventoryCloseEvent event) { + Integer slot = open.remove(event.getPlayer().getUniqueId()); + if (slot == null || !(event.getPlayer() instanceof Player player)) { + return; + } + ItemStack expander = player.getInventory().getItem(slot); + if (!addon.getHoldService().isExpander(expander) + || !(expander.getItemMeta() instanceof BlockStateMeta meta) + || !(meta.getBlockState() instanceof ShulkerBox box)) { + // They swapped the item away mid-edit: give the contents back + event.getInventory().forEach(stack -> { + if (stack != null) { + player.getWorld().dropItem(player.getLocation(), stack); + } + }); + return; + } + box.getInventory().setContents(event.getInventory().getContents()); + meta.setBlockState(box); + expander.setItemMeta(meta); + player.getInventory().setItem(slot, expander); + player.playSound(player.getLocation(), Sound.BLOCK_SHULKER_BOX_CLOSE, 0.6f, 1.2f); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 2e8c9fc..2dd8f39 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -33,9 +33,12 @@ galaxy: # where players spawn and respawn. A working port: dock, plaza, market, warp zone. # AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN, or RANDOM. spawn-island-type: FISHING - # Chance (0-1) that a galaxy cell WITHOUT a trading island hosts a wild islet - - # small unnamed islands, unprotected: mine, farm, build, live. + # Chance (0-1) that a wild-islet grid cell hosts one - small unnamed islands, + # unprotected: mine, farm, build, live. wild-islet-chance: 0.3 + # Grid size in blocks for wild islets - much finer than the trading island grid, + # so the open sea is dotted with land you can put ashore on. + wild-islet-grid: 1200 # Terrain radius of wild islets. 0 disables them. wild-islet-radius: 70 # Relative spawn weight per island type. Higher = more common; 0 disables a type. @@ -131,6 +134,9 @@ chart: hologram-duration-seconds: 15 # Maximum holograms shown at once (nearest islands first). hologram-max: 12 + # How close a sailor must pass an island to chart it. Slightly beyond island waters, + # so islands are charted as they are sighted. Only charted islands can be warped to. + sighting-range: 1200 # Show the hologram chart automatically whenever a player boards a boat. show-on-boarding: true # Star Chart map scale: blocks per map pixel. 64 shows ~8km across. diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index 9f8be51..9f0ffb0 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -267,15 +267,43 @@ void testWildIslets() { // Terrain rises there, with a vanilla wild biome assertEquals(45, engine.landLiftAt(sample[0], sample[1])); assertTrue(engine.biomeKeyAt(sample[0], sample[1]).orElseThrow().startsWith("minecraft:")); - // Never inside a trading island's cell + // Islets keep well clear of trading islands (terrain + islet + margin) GalaxyEngine dense = new GalaxyEngine(config(SEED, 1.0)); - for (int cx = -5; cx <= 5; cx++) { - for (int cz = -5; cz <= 5; cz++) { - if (dense.islandInCell(cx, cz).isPresent()) { - assertTrue(dense.wildIsletInCell(cx, cz).isEmpty(), - "Cell with a trading island must not also host a wild islet"); + for (int cx = -20; cx <= 20; cx++) { + for (int cz = -20; cz <= 20; cz++) { + java.util.Optional islet = dense.wildIsletInCell(cx, cz); + if (islet.isEmpty()) { + continue; + } + for (IslandSpec spec : dense.islandsNear(islet.get()[0], islet.get()[1], 2000)) { + double d = Math.sqrt(spec.distanceSquared(islet.get()[0], islet.get()[1])); + assertTrue(d >= 160 + 70, + "Islet at " + islet.get()[0] + "," + islet.get()[1] + " crowds " + spec.name()); + } + } + } + } + + @Test + void testIsletsAreFindable() { + // The open sea must not be empty: an islet within a short row of + // anywhere (playtest: 6000x6000 blocks of nothing at 10000,10000) + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.5)); + for (int[] point : new int[][] { { 10000, 10000 }, { -5000, 15000 }, { 30000, -20000 } }) { + double nearest = Double.MAX_VALUE; + int grid = GalaxyConfig.DEFAULT_WILD_GRID; + int cx = Math.floorDiv(point[0], grid); + int cz = Math.floorDiv(point[1], grid); + for (int i = cx - 4; i <= cx + 4; i++) { + for (int j = cz - 4; j <= cz + 4; j++) { + java.util.Optional islet = engine.wildIsletInCell(i, j); + if (islet.isPresent()) { + nearest = Math.min(nearest, + Math.hypot(islet.get()[0] - point[0], islet.get()[1] - point[1])); + } } } + assertTrue(nearest < 2500, "No islet within 2500 blocks of " + point[0] + "," + point[1]); } } From 40b3e8986ba6c5f9249c75aae98c723a83f1061f Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 19:06:41 -0700 Subject: [PATCH 039/112] Fix the pouch soft-lock: cheap first pouch and a harbourmaster's charity With no pouch there is no hold, and with no hold a player can neither buy nor sell - so a dead sailor could not earn the money to buy the pouch that lets them earn. The entry ticket was priced as an upgrade. First pouch now costs 50 (was 250), each further one x3 (50/150/450); the 3-pouch cap, not the price, is what makes expanders necessary. Any market also offers the harbourmaster's charity to a sailor with no cargo space, no boat and no money: a free pouch, plus a hull if needed, on a 15 minute cooldown. Charity goods are unstamped and so cannot be sold - there is nothing to farm. 149 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 10 +++ docs/PROGRESS.md | 20 +++++ .../world/bentobox/tradewinds/Settings.java | 23 ++++- .../tradewinds/dataobjects/TWPlayerData.java | 14 +++ .../tradewinds/economy/MarketService.java | 75 +++++++++++++++- .../tradewinds/economy/TradeDialog.java | 17 +++- src/main/resources/config.yml | 12 ++- src/main/resources/locales/en-US.yml | 3 + .../tradewinds/economy/CharityTest.java | 89 +++++++++++++++++++ 9 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 src/test/java/world/bentobox/tradewinds/economy/CharityTest.java diff --git a/TESTING.md b/TESTING.md index fe5a883..1a868dd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -474,3 +474,13 @@ database (or use `/twadmin delete` on it), then restart. - [ ] Rowing within ~1200 blocks of an uncharted island charts it ("Charted !" on the action bar) and it then appears in /tw chart, the warp dialog and the star chart. `chart.sighting-range` tunes this. + +### Stage 5g — recovery: cheap first pouch and the harbourmaster's charity +- [ ] A first Trading Pouch costs $50 (was $250); the second $150, the third $450. + The 3-pouch cap — not the price — is what makes expanders worth buying. +- [ ] **Soft-lock check**: with no pouch, no expander and under $50, the Shipwright + shows "Harbourmaster's charity": claim it for a free pouch (plus a boat if you + have none). You can immediately buy goods and trade again. +- [ ] The charity refuses if you are NOT destitute ("You'll manage"), and again + within 15 minutes of a claim ("Come back in N minutes"). +- [ ] Charity goods cannot be sold (unstamped), so repeat claims yield no money. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a5d1393..4b33dcb 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,26 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Recovery: the pouch soft-lock and the harbourmaster (2026-07-31) + +Ben died, respawned with under $250, and could not afford a pouch. Worse than +a price problem: with NO pouch there is no hold, and with no hold a player can +neither buy nor sell - the market moves goods through the hold on both sides - +so a dead player could not earn the money to buy the thing that lets them +earn. A hard soft-lock, and my fault for pricing the entry ticket as an +upgrade. +Two fixes: +- **First pouch $50** (was 250), each further one x3 (50/150/450). The + 3-pouch CAP, not the price, is what makes expanders necessary - so pouches + can be affordable without undermining the money sink. A test asserts the + starting balance always covers a pouch plus a boat. +- **The harbourmaster's charity**: at any market, a sailor with no cargo + space, no boat and too little money to buy either can claim a free pouch + (and a hull if needed), on a config cooldown (15 min). Unabusable by + construction: charity goods are unstamped, so they cannot be sold for + money, and the button only appears while genuinely destitute. +149 tests green. + ## Playtest fixes: empty ocean, unopenable expanders (2026-07-31) Ben teleported to 10000,10000 and found nothing at all. Measured: nearest diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index bc835b7..06d1d47 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -360,11 +360,22 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "economy.max-bundles") private int maxBundles = 3; - @ConfigComment("Price of a trading pouch from the shipwright. A pouch holds 64 items, so at") - @ConfigComment("the default prices it is deliberately worse value per item than a cargo") - @ConfigComment("expander (27 stacks for 5000) - pouches are the early rung, expanders the goal.") + @ConfigComment("Price of a sailor's FIRST trading pouch. Deliberately cheap: with no pouch a") + @ConfigComment("player has no hold, and with no hold they can neither buy nor sell - so the") + @ConfigComment("first pouch is the price of being able to play at all, not an upgrade.") @ConfigEntry(path = "economy.pouch-price") - private double pouchPrice = 250.0; + private double pouchPrice = 50.0; + + @ConfigComment("Each further pouch costs this much more than the last (50, 150, 450...).") + @ConfigComment("The 3-pouch cap is what makes cargo expanders necessary, not the price.") + @ConfigEntry(path = "economy.pouch-price-multiplier") + private double pouchPriceMultiplier = 3.0; + + @ConfigComment("The harbourmaster's charity: a destitute sailor - no cargo space, no boat, and") + @ConfigComment("too little money to buy either - is given the bare minimum to work again.") + @ConfigComment("Charity goods cannot be sold, so there is nothing to farm. 0 disables it.") + @ConfigEntry(path = "economy.charity-cooldown-minutes") + private int charityCooldownMinutes = 15; @ConfigComment("Give customs-stamped goods an enchantment glint as well as their lore line.") @ConfigEntry(path = "economy.stamp-glint") @@ -2476,6 +2487,10 @@ public void setConcurrentIslands(int concurrentIslands) { public void setMaxBundles(int maxBundles) { this.maxBundles = maxBundles; } public double getPouchPrice() { return pouchPrice; } public void setPouchPrice(double pouchPrice) { this.pouchPrice = pouchPrice; } + public double getPouchPriceMultiplier() { return pouchPriceMultiplier; } + public void setPouchPriceMultiplier(double pouchPriceMultiplier) { this.pouchPriceMultiplier = pouchPriceMultiplier; } + public int getCharityCooldownMinutes() { return charityCooldownMinutes; } + public void setCharityCooldownMinutes(int charityCooldownMinutes) { this.charityCooldownMinutes = charityCooldownMinutes; } public boolean isStampGlint() { return stampGlint; } public void setStampGlint(boolean stampGlint) { this.stampGlint = stampGlint; } public List getUnstampedSellables() { return unstampedSellables; } diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index 43224fa..7d9d7ee 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -46,6 +46,12 @@ public class TWPlayerData implements DataObject { @Expose private int restartsUsed; + /** + * Epoch millis of the last harbourmaster's charity claim. + */ + @Expose + private long lastCharity; + public TWPlayerData() { // Required by the database } @@ -94,6 +100,14 @@ public void setExpandersPurchased(int expandersPurchased) { this.expandersPurchased = expandersPurchased; } + public long getLastCharity() { + return lastCharity; + } + + public void setLastCharity(long lastCharity) { + this.lastCharity = lastCharity; + } + public int getRestartsUsed() { return restartsUsed; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 363bd7e..ca1986f 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -358,6 +358,79 @@ public ItemStack pouchItem() { * * @return true if bought */ + /** + * What the next pouch costs: cheap for a sailor with none, escalating for + * each one already carried. + */ + public double pouchPrice(Player player) { + return addon.getSettings().getPouchPrice() + * Math.pow(addon.getSettings().getPouchPriceMultiplier(), + addon.getHoldService().pouchCount(player)); + } + + /** + * Is this sailor destitute - no cargo space, and unable to buy any? + */ + public boolean isDestitute(Player player) { + if (addon.getHoldService().pouchCount(player) > 0 || !addon.getHoldService().expanders(player).isEmpty()) { + return false; + } + return addon.getPlugin().getVault() + .map(vault -> vault.getBalance(User.getInstance(player)) < addon.getSettings().getPouchPrice()) + .orElse(false); + } + + /** + * The harbourmaster's charity: a sailor with no hold and no money to buy + * one cannot earn anything at all - the market needs cargo space on both + * sides of a trade - so the port gives them the bare minimum to work + * again. Charity goods are unstamped and therefore unsellable, so there is + * nothing here to farm. + * + * @return true if something was given + */ + public boolean claimCharity(Player player) { + User user = User.getInstance(player); + var data = addon.getPlayerDataManager().get(player.getUniqueId()); + int cooldown = addon.getSettings().getCharityCooldownMinutes(); + if (cooldown <= 0 || !isDestitute(player)) { + user.sendMessage("tradewinds.trade.charity-not-needed"); + thud(player); + return false; + } + long wait = data.getLastCharity() + cooldown * 60_000L - System.currentTimeMillis(); + if (wait > 0) { + user.sendMessage("tradewinds.trade.charity-cooldown", "[number]", + String.valueOf(Math.max(1, wait / 60_000L))); + thud(player); + return false; + } + data.setLastCharity(System.currentTimeMillis()); + addon.getPlayerDataManager().save(player.getUniqueId()); + player.getInventory().addItem(pouchItem()).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + // A hull too, if they have neither boat nor boat item + if (!hasBoat(player)) { + player.getInventory().addItem(new ItemStack(Material.OAK_BOAT)).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + } + user.sendMessage("tradewinds.trade.charity-given"); + chime(player); + return true; + } + + private boolean hasBoat(Player player) { + if (player.getVehicle() instanceof org.bukkit.entity.Boat) { + return true; + } + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getType().name().endsWith("_BOAT")) { + return true; + } + } + return false; + } + public boolean buyPouch(Player player) { Optional vault = addon.getPlugin().getVault(); if (vault.isEmpty()) { @@ -369,7 +442,7 @@ public boolean buyPouch(Player player) { thud(player); return false; } - double price = addon.getSettings().getPouchPrice(); + double price = pouchPrice(player); if (!vault.get().has(user, price)) { user.sendMessage("tradewinds.trade.cannot-afford"); thud(player); diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 5b0e9c3..f0e93c9 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -187,10 +187,10 @@ public void openShipwright(Player player, IslandSpec spec) { int pouches = addon.getHoldService().pouchCount(player); int maxPouches = addon.getSettings().getMaxBundles(); if (pouches < maxPouches) { - buttons.add(button(String.format("Trading Pouch - $%.0f", addon.getSettings().getPouchPrice()), - NamedTextColor.GOLD, - "Holds 64 items (" + pouches + "/" + maxPouches + " carried). Cheap to start with; a " - + "cargo expander is far better value once you can afford one.", + buttons.add(button(String.format("Trading Pouch - $%.0f", + addon.getMarketService().pouchPrice(player)), NamedTextColor.GOLD, + "Holds 64 items (" + pouches + "/" + maxPouches + " carried). Each further pouch costs " + + "more; expanders carry far more per slot.", () -> { addon.getMarketService().buyPouch(player); openShipwright(player, spec); @@ -207,6 +207,15 @@ public void openShipwright(Player player, IslandSpec spec) { openShipwright(player, spec); })); } + if (addon.getMarketService().isDestitute(player)) { + buttons.add(button("Harbourmaster's charity", NamedTextColor.GREEN, + "Shipwrecked and penniless? Take a pouch, and a boat if you need one. " + + "Nobody starves in this port.", + () -> { + addon.getMarketService().claimCharity(player); + openShipwright(player, spec); + })); + } show(player, spec.name() + " - Shipwright", List.of("Hulls and holds. Smart sailors craft their own boats from wild timber.", statusLine(player)), diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 2dd8f39..011af81 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -169,9 +169,15 @@ economy: expander-cap: 4 # Maximum trading pouches (bundles) that count as hold space. max-bundles: 3 - # Price of a trading pouch. A pouch holds 64 items, so at the default prices it is - # deliberately worse value per item than an expander - pouches are the early rung. - pouch-price: 250.0 + # Price of a sailor's FIRST trading pouch. Deliberately cheap: with no pouch there + # is no hold, and with no hold a player can neither buy nor sell - so the first + # pouch is the price of playing at all, not an upgrade. + pouch-price: 50.0 + # Each further pouch costs this much more than the last (50, 150, 450...). + pouch-price-multiplier: 3.0 + # The harbourmaster's charity: a sailor with no cargo space, no boat and no money + # is given the bare minimum to work again. Charity goods cannot be sold. 0 disables. + charity-cooldown-minutes: 15 # Give customs-stamped goods an enchantment glint as well as their lore line. stamp-glint: false # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 536a8eb..afa34fd 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -70,6 +70,9 @@ tradewinds: expander-bought: "&6 Cargo expander purchased for $[price] - carried in your pack." pouch-bought: "&6 Trading pouch purchased for $[price]." pouch-cap: "&c You cannot carry any more trading pouches." + charity-given: "&a The harbourmaster hands you a pouch. 'Get back to work, sailor.'" + charity-not-needed: "&c The harbourmaster looks you over. 'You'll manage.'" + charity-cooldown: "&c 'I helped you already. Come back in [number] minutes.'" expander-cap: "&c Your boat cannot take any more expanders." interstice: stranded: "&4 The warp collapses! Dark water, and screaming above it." diff --git a/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java b/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java new file mode 100644 index 0000000..24e9a62 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java @@ -0,0 +1,89 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.bukkit.inventory.ItemStack; +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.TradeWinds; +import world.bentobox.tradewinds.dataobjects.IslandDataManager; +import world.bentobox.tradewinds.travel.HoldService; + +/** + * The recovery rules: a sailor with no hold cannot earn anything (the market + * needs cargo space on both sides), so the first pouch must be cheap and the + * penniless must have a way back. + * + * @author tastybento + */ +class CharityTest extends CommonTestSetup { + + private TradeWinds addon; + private MarketService service; + private HoldService hold; + private Settings settings; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + when(addon.getIslandDataManager()).thenReturn(mock(IslandDataManager.class)); + when(addon.getPlugin()).thenReturn(plugin); + when(plugin.getVault()).thenReturn(java.util.Optional.empty()); + hold = mock(HoldService.class); + when(addon.getHoldService()).thenReturn(hold); + service = new MarketService(addon); + } + + @Test + void testFirstPouchIsCheapEnoughToRecover() { + // The starting balance must comfortably cover a first pouch AND a boat, + // or a dead player is soft-locked out of the whole economy + assertTrue(settings.getPouchPrice() + 20 < settings.getStartingBalance(), + "A sailor must be able to re-equip from the starting balance"); + } + + @Test + void testPouchPriceEscalates() { + when(hold.pouchCount(mockPlayer)).thenReturn(0); + assertEquals(50.0, service.pouchPrice(mockPlayer)); + when(hold.pouchCount(mockPlayer)).thenReturn(1); + assertEquals(150.0, service.pouchPrice(mockPlayer)); + when(hold.pouchCount(mockPlayer)).thenReturn(2); + assertEquals(450.0, service.pouchPrice(mockPlayer)); + } + + @Test + void testDestituteMeansNoHoldAndNoMoney() { + when(hold.pouchCount(mockPlayer)).thenReturn(0); + when(hold.expanders(mockPlayer)).thenReturn(List.of()); + // Without an economy there is nothing to be destitute about + assertFalse(service.isDestitute(mockPlayer)); + // Carrying a pouch is never destitute + when(hold.pouchCount(mockPlayer)).thenReturn(1); + assertFalse(service.isDestitute(mockPlayer)); + // Nor is carrying an expander + when(hold.pouchCount(mockPlayer)).thenReturn(0); + when(hold.expanders(mockPlayer)).thenReturn(List.of(mock(ItemStack.class))); + assertFalse(service.isDestitute(mockPlayer)); + } + + @Test + void testCharityGoodsCannotBeSold() { + // The pouch the harbourmaster gives is unstamped, so it cannot be sold + // back for money - there is nothing to farm + assertFalse(service.isStamped(service.pouchItem())); + } +} From 77a5981d610045cf2e23aa825bca4469f6b26723 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 19:11:35 -0700 Subject: [PATCH 040/112] Flat pouch price: escalation was defeated by dropping a pouch Pricing pouches by how many the sailor carries could be gamed by dropping one before buying and picking it back up. The price is flat now and the max-bundles cap does the limiting - which is also what makes cargo expanders necessary, so nothing is lost. 149 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 5 +++-- docs/PROGRESS.md | 11 +++++++---- .../java/world/bentobox/tradewinds/Settings.java | 15 +++++---------- .../tradewinds/economy/MarketService.java | 9 ++++----- .../bentobox/tradewinds/economy/TradeDialog.java | 4 ++-- src/main/resources/config.yml | 9 ++++----- .../bentobox/tradewinds/economy/CharityTest.java | 14 +++++++------- 7 files changed, 32 insertions(+), 35 deletions(-) diff --git a/TESTING.md b/TESTING.md index 1a868dd..0de1455 100644 --- a/TESTING.md +++ b/TESTING.md @@ -476,8 +476,9 @@ database (or use `/twadmin delete` on it), then restart. star chart. `chart.sighting-range` tunes this. ### Stage 5g — recovery: cheap first pouch and the harbourmaster's charity -- [ ] A first Trading Pouch costs $50 (was $250); the second $150, the third $450. - The 3-pouch cap — not the price — is what makes expanders worth buying. +- [ ] Trading Pouches cost a flat $50 each, up to the 3-pouch cap. (Escalating prices + were dropped: dropping a pouch before buying reset the count. The cap — not the + price — is what makes expanders worth buying.) - [ ] **Soft-lock check**: with no pouch, no expander and under $50, the Shipwright shows "Harbourmaster's charity": claim it for a free pouch (plus a boat if you have none). You can immediately buy goods and trade again. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4b33dcb..4175492 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -12,10 +12,13 @@ so a dead player could not earn the money to buy the thing that lets them earn. A hard soft-lock, and my fault for pricing the entry ticket as an upgrade. Two fixes: -- **First pouch $50** (was 250), each further one x3 (50/150/450). The - 3-pouch CAP, not the price, is what makes expanders necessary - so pouches - can be affordable without undermining the money sink. A test asserts the - starting balance always covers a pouch plus a boat. +- **Pouches $50 flat** (was 250). First tried escalating prices (50/150/450) + keyed off carried pouches; Ben immediately spotted the hole - drop a pouch, + buy at the base price, pick it back up. Any per-pouch escalation is + defeated the same way, so the price is flat and the max-bundles CAP does + the limiting; that is also what keeps expanders necessary. A test asserts + the starting balance always covers a pouch plus a boat, and another pins + the price flat regardless of how many are carried. - **The harbourmaster's charity**: at any market, a sailor with no cargo space, no boat and too little money to buy either can claim a free pouch (and a hull if needed), on a config cooldown (15 min). Unabusable by diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 06d1d47..9ba8166 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -360,17 +360,14 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "economy.max-bundles") private int maxBundles = 3; - @ConfigComment("Price of a sailor's FIRST trading pouch. Deliberately cheap: with no pouch a") - @ConfigComment("player has no hold, and with no hold they can neither buy nor sell - so the") - @ConfigComment("first pouch is the price of being able to play at all, not an upgrade.") + @ConfigComment("Price of a trading pouch. Deliberately cheap: with no pouch a player has no") + @ConfigComment("hold, and with no hold they can neither buy nor sell - so a pouch is the price") + @ConfigComment("of being able to play at all, not an upgrade. Flat by design: pricing by how") + @ConfigComment("many you carry is defeated by dropping one before buying, and the max-bundles") + @ConfigComment("cap - not the price - is what makes cargo expanders necessary.") @ConfigEntry(path = "economy.pouch-price") private double pouchPrice = 50.0; - @ConfigComment("Each further pouch costs this much more than the last (50, 150, 450...).") - @ConfigComment("The 3-pouch cap is what makes cargo expanders necessary, not the price.") - @ConfigEntry(path = "economy.pouch-price-multiplier") - private double pouchPriceMultiplier = 3.0; - @ConfigComment("The harbourmaster's charity: a destitute sailor - no cargo space, no boat, and") @ConfigComment("too little money to buy either - is given the bare minimum to work again.") @ConfigComment("Charity goods cannot be sold, so there is nothing to farm. 0 disables it.") @@ -2487,8 +2484,6 @@ public void setConcurrentIslands(int concurrentIslands) { public void setMaxBundles(int maxBundles) { this.maxBundles = maxBundles; } public double getPouchPrice() { return pouchPrice; } public void setPouchPrice(double pouchPrice) { this.pouchPrice = pouchPrice; } - public double getPouchPriceMultiplier() { return pouchPriceMultiplier; } - public void setPouchPriceMultiplier(double pouchPriceMultiplier) { this.pouchPriceMultiplier = pouchPriceMultiplier; } public int getCharityCooldownMinutes() { return charityCooldownMinutes; } public void setCharityCooldownMinutes(int charityCooldownMinutes) { this.charityCooldownMinutes = charityCooldownMinutes; } public boolean isStampGlint() { return stampGlint; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index ca1986f..33cf6e8 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -359,13 +359,12 @@ public ItemStack pouchItem() { * @return true if bought */ /** - * What the next pouch costs: cheap for a sailor with none, escalating for - * each one already carried. + * What a pouch costs. Flat: pricing by how many the sailor carries would be + * defeated by dropping one before buying and picking it up afterwards. The + * max-bundles cap does the limiting instead. */ public double pouchPrice(Player player) { - return addon.getSettings().getPouchPrice() - * Math.pow(addon.getSettings().getPouchPriceMultiplier(), - addon.getHoldService().pouchCount(player)); + return addon.getSettings().getPouchPrice(); } /** diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index f0e93c9..1fa8fde 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -189,8 +189,8 @@ public void openShipwright(Player player, IslandSpec spec) { if (pouches < maxPouches) { buttons.add(button(String.format("Trading Pouch - $%.0f", addon.getMarketService().pouchPrice(player)), NamedTextColor.GOLD, - "Holds 64 items (" + pouches + "/" + maxPouches + " carried). Each further pouch costs " - + "more; expanders carry far more per slot.", + "Holds 64 items (" + pouches + "/" + maxPouches + " carried). Cargo expanders carry " + + "27 stacks each - the way to a real hold.", () -> { addon.getMarketService().buyPouch(player); openShipwright(player, spec); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 011af81..ee28d04 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -169,12 +169,11 @@ economy: expander-cap: 4 # Maximum trading pouches (bundles) that count as hold space. max-bundles: 3 - # Price of a sailor's FIRST trading pouch. Deliberately cheap: with no pouch there - # is no hold, and with no hold a player can neither buy nor sell - so the first - # pouch is the price of playing at all, not an upgrade. + # Price of a trading pouch. Deliberately cheap: with no pouch there is no hold, and + # with no hold a player can neither buy nor sell - so a pouch is the price of + # playing at all, not an upgrade. Flat by design: pricing by how many you carry is + # defeated by dropping one before buying; the max-bundles cap does the limiting. pouch-price: 50.0 - # Each further pouch costs this much more than the last (50, 150, 450...). - pouch-price-multiplier: 3.0 # The harbourmaster's charity: a sailor with no cargo space, no boat and no money # is given the bare minimum to work again. Charity goods cannot be sold. 0 disables. charity-cooldown-minutes: 15 diff --git a/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java b/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java index 24e9a62..1556a1c 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java @@ -56,13 +56,13 @@ void testFirstPouchIsCheapEnoughToRecover() { } @Test - void testPouchPriceEscalates() { - when(hold.pouchCount(mockPlayer)).thenReturn(0); - assertEquals(50.0, service.pouchPrice(mockPlayer)); - when(hold.pouchCount(mockPlayer)).thenReturn(1); - assertEquals(150.0, service.pouchPrice(mockPlayer)); - when(hold.pouchCount(mockPlayer)).thenReturn(2); - assertEquals(450.0, service.pouchPrice(mockPlayer)); + void testPouchPriceIsFlatSoItCannotBeGamed() { + // Pricing by carried pouches would be defeated by dropping one before + // buying and picking it up again; the cap does the limiting instead + for (int carried = 0; carried < 3; carried++) { + when(hold.pouchCount(mockPlayer)).thenReturn(carried); + assertEquals(settings.getPouchPrice(), service.pouchPrice(mockPlayer)); + } } @Test From 851251f080951dad6bf1eafdb53963b978083da0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 19:30:57 -0700 Subject: [PATCH 041/112] Port scan: opening the chart at an island charts its neighbours Discovery was sighting-only, so warping between known ports never revealed anything and the warp dialog stayed at the starter cluster. Opening the chart at a trading island - or boarding a boat there - now copies that port's harbour charts: the nearest chart.port-scan (8, matching the warp dialog cap) islands are charted free. It requires standing at the port rather than merely in its waters, so discovery still needs landfall, and the map spreads outward port by port as players trade. 149 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 11 +++++ docs/PROGRESS.md | 13 ++++++ .../world/bentobox/tradewinds/Settings.java | 8 ++++ .../tradewinds/commands/TWChartCommand.java | 7 +++ .../dataobjects/PlayerDataManager.java | 44 +++++++++++++++++++ .../tradewinds/travel/ChartHolograms.java | 7 +++ src/main/resources/config.yml | 3 ++ src/main/resources/locales/en-US.yml | 1 + 8 files changed, 94 insertions(+) diff --git a/TESTING.md b/TESTING.md index 0de1455..9a6f12b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -485,3 +485,14 @@ database (or use `/twadmin delete` on it), then restart. - [ ] The charity refuses if you are NOT destitute ("You'll manage"), and again within 15 minutes of a claim ("Come back in N minutes"). - [ ] Charity goods cannot be sold (unstamped), so repeat claims yield no money. + +### Stage 5h — the port scan (free chart at a trading island) +- [ ] Stand on any trading island and run `/tw chart` (or board a boat there): "You + copy the harbour charts: N islands added to yours." +- [ ] Those islands immediately appear in `/tw chart`, the hologram compass, the star + chart, and — the point of it — the warp dialog, so docking anywhere leaves you + with a full menu of onward routes. +- [ ] Running it again at the same port adds nothing (they are already charted). +- [ ] Opening the chart at sea (not at an island) does NOT scan — discovery still + requires making landfall. +- [ ] `chart.port-scan: 0` disables the free scan. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4175492..23b0906 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,19 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The port scan: charts spread from port to port (2026-07-31) + +Discovery was sighting-only, so a player who warped everywhere never learned +anything new and the warp dialog stayed at the starter cluster. Ben's fix: +opening the chart AT a trading island copies that port's harbour charts - +the nearest `chart.port-scan` (8, matching the warp dialog cap) islands are +charted free. Implemented as `PlayerDataManager.portScan(player)`, called +from the chart command and from ChartHolograms.show (so boarding a boat at a +port scans too). Requires being within the protection range - at the port, +not merely in its waters - so discovery still needs landfall, and each port +teaches its own neighbourhood, which spreads the map outward naturally as +players trade. + ## Recovery: the pouch soft-lock and the harbourmaster (2026-07-31) Ben died, respawned with under $250, and could not afford a pouch. Worse than diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 9ba8166..66db50d 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -202,6 +202,12 @@ private static Map defaultBandHurtVillagers() { @ConfigEntry(path = "chart.hologram-max") private int chartHologramMax = 12; + @ConfigComment("Islands added to a sailor's chart for free when they open the chart while at a") + @ConfigComment("trading island - the port's own harbour charts. 8 matches the warp dialog, so") + @ConfigComment("docking anywhere leaves with a full menu of onward routes. 0 disables.") + @ConfigEntry(path = "chart.port-scan") + private int portScan = 8; + @ConfigComment("How close a sailor must pass an island to chart it, in blocks. Slightly beyond") @ConfigComment("island waters (distance-between-islands) so islands are charted as they are") @ConfigComment("sighted. Only charted islands can be warped to.") @@ -2446,6 +2452,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setChartHologramSeconds(int chartHologramSeconds) { this.chartHologramSeconds = chartHologramSeconds; } public int getChartHologramMax() { return chartHologramMax; } public void setChartHologramMax(int chartHologramMax) { this.chartHologramMax = chartHologramMax; } + public int getPortScan() { return portScan; } + public void setPortScan(int portScan) { this.portScan = portScan; } public int getChartSightingRange() { return chartSightingRange; } public void setChartSightingRange(int chartSightingRange) { this.chartSightingRange = chartSightingRange; } public boolean isChartOnBoarding() { return chartOnBoarding; } diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java index 95c56e1..32d8743 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -31,6 +31,13 @@ public void setup() { @Override public boolean execute(User user, String label, List args) { TradeWinds addon = getAddon(); + // At a port? Copy its harbour charts first - a free scan of the + // neighbouring islands + List scanned = addon.getPlayerDataManager().portScan(user.getPlayer()); + if (!scanned.isEmpty()) { + user.sendMessage("tradewinds.chart.port-scan", TextVariables.NUMBER, + String.valueOf(scanned.size())); + } // In a boat (and not asking for the text list): raise the hologram // compass - visual targets to row toward boolean wantList = !args.isEmpty() && args.get(0).equalsIgnoreCase("list"); diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java b/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java index ba23fc1..25f831d 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java @@ -21,6 +21,9 @@ public class PlayerDataManager { private final Database handler; private final Map cache = new ConcurrentHashMap<>(); + /** How far a port's harbour charts reach when scanning for neighbours. */ + private static final int PORT_SCAN_RADIUS = 50_000; + public PlayerDataManager(TradeWinds addon) { this.addon = addon; this.handler = new Database<>(addon, TWPlayerData.class); @@ -58,6 +61,47 @@ private void chartStarterCluster(TWPlayerData data) { engine.islandsNear(0, 0, addon.getSettings().getStarterClusterRadius() * 2).forEach(data::chart); } + /** + * The port scan: a sailor standing at a trading island may read its + * harbour charts, so the nearest islands are added to their own chart + * free of charge. Dock anywhere and you leave knowing your neighbours. + * + * @param player the sailor, expected to be at an island + * @return the islands newly charted + */ + public java.util.List portScan(org.bukkit.entity.Player player) { + int count = addon.getSettings().getPortScan(); + if (count <= 0 || addon.getOverWorld() == null || !player.getWorld().equals(addon.getOverWorld())) { + return java.util.List.of(); + } + GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + int x = player.getLocation().getBlockX(); + int z = player.getLocation().getBlockZ(); + // Must actually be AT a port, not merely in its waters + int range = addon.getSettings().getIslandProtectionRange(); + java.util.Optional port = engine.islandsNear(x, z, range) + .stream().filter(spec -> spec.distanceSquared(x, z) <= (long) range * range).findFirst(); + if (port.isEmpty()) { + return java.util.List.of(); + } + world.bentobox.tradewinds.galaxy.IslandSpec here = port.get(); + TWPlayerData data = get(player.getUniqueId()); + java.util.List charted = engine + .islandsNear(here.centerX(), here.centerZ(), PORT_SCAN_RADIUS).stream() + .sorted(java.util.Comparator + .comparingLong(spec -> spec.distanceSquared(here.centerX(), here.centerZ()))) + .limit(count) + .filter(data::chart) + .toList(); + if (!charted.isEmpty()) { + save(player.getUniqueId()); + charted.forEach(spec -> org.bukkit.Bukkit.getPluginManager() + .callEvent(new world.bentobox.tradewinds.api.events.IslandChartedEvent(player.getUniqueId(), + spec))); + } + return charted; + } + public void save(UUID playerId) { TWPlayerData data = cache.get(playerId); if (data != null) { diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index 29d8e50..c7f03e5 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -89,6 +89,13 @@ static List markers(List islands, int px, int pz, double rad */ public void show(Player player) { clear(player.getUniqueId()); + // Boarding or charting at a port copies its harbour charts + List scanned = addon.getPlayerDataManager().portScan(player); + if (!scanned.isEmpty()) { + world.bentobox.bentobox.api.user.User.getInstance(player).sendMessage("tradewinds.chart.port-scan", + world.bentobox.bentobox.api.localization.TextVariables.NUMBER, + String.valueOf(scanned.size())); + } List charted = chartedIslands(player); if (charted.isEmpty()) { return; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ee28d04..b6c39e1 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -134,6 +134,9 @@ chart: hologram-duration-seconds: 15 # Maximum holograms shown at once (nearest islands first). hologram-max: 12 + # Islands added to a sailor's chart for free when they open the chart at a trading + # island - the port's own harbour charts. 8 matches the warp dialog. 0 disables. + port-scan: 8 # How close a sailor must pass an island to chart it. Slightly beyond island waters, # so islands are charted as they are sighted. Only charted islands can be warped to. sighting-range: 1200 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index afa34fd..54b2247 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -48,6 +48,7 @@ tradewinds: clean: "Clean" chart: charted: "Charted [name]!" + port-scan: "&b You copy the harbour charts: [number] islands added to yours." holograms-shown: "&b Your chart shimmers into the air - row toward a name." starchart-given: "&b The Star Chart is yours - hold it to see your world." empty: "&c Your chart is empty - row into an island's waters to chart it." From 76b32afb9c3a3e58faaeed133b36215fa3f6f934 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 19:43:37 -0700 Subject: [PATCH 042/112] Make sea encounters fight: re-target, and crews abandon ship The sea witch drifted harmlessly because a mob riding a boat cannot run its attack goals, and because the target was set once at spawn with nothing to re-assert it. EncounterService now runs a 2s aggression pass over encounter-tagged mobs near players: re-target when the target is gone, and leave the boat when the quarry is within 14 blocks, so pirate crews and the witch come over the side to fight. Swimming encounters keep hunting instead of forgetting. 149 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 5 +++ docs/PROGRESS.md | 13 ++++++ .../encounters/EncounterService.java | 42 +++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/TESTING.md b/TESTING.md index 9a6f12b..1837a1a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -394,6 +394,11 @@ temporarily to force it. - [ ] Lawless/anarchic water adds worse things: Zombie Nautilus (26.2's undead sea horror), phantoms at night, pillager PIRATE CREWS in their own boat, and the sea witch. + - ~~The witch adrift did nothing and was easy to kill~~ FIXED: mobs riding a + boat cannot run their attack goals, and the target was set once at spawn. + An aggression pass now re-asserts targets every 2s, and boated crews + ABANDON SHIP when you come within ~14 blocks — pirates and the witch should + now come over the side at you. - [ ] Safe island waters are quiet (2% base, further reduced near the dock); encounters intensify with distance from any island. - [ ] Killing encounter mobs sometimes drops booty (nautilus shells, tridents, diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 23b0906..064fcd3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,19 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest fix: the harmless sea witch (2026-07-31) + +The witch adrift did nothing and died easily. Two causes: a mob riding a boat +cannot run its attack goals at all (so the boated encounters - SEA_WITCH and +PIRATE_CREW - were ornaments), and the target was set once at spawn with +nothing to re-assert it, so any mob that lost interest stayed lost. +EncounterService now runs a 2s aggression pass over encounter-tagged mobs +near players: re-target when the target is gone, and ABANDON SHIP (leaveVehicle) +when the quarry is within 14 blocks - crews come over the side to fight, +which is both functional and the right image. This also stiffens the swimming +encounters (drowned, nautilus, guardians), which previously forgot their +quarry after a short chase. + ## The port scan: charts spread from port to port (2026-07-31) Discovery was sighting-only, so a player who warped everywhere never learned diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java index 9648f5a..07288cb 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java @@ -39,8 +39,14 @@ public class EncounterService { /** PDC key marking encounter mobs (booty, cleanup). */ public static final NamespacedKey ENCOUNTER_KEY = NamespacedKey.fromString("tradewinds:encounter"); + /** How close before a boated crew abandons ship to attack. */ + private static final double BOARDING_RANGE = 14.0; + /** How far encounter mobs keep hunting. */ + private static final double HUNT_RANGE = 48.0; + private final TradeWinds addon; private BukkitTask task; + private BukkitTask aggression; public EncounterService(TradeWinds addon) { this.addon = addon; @@ -49,12 +55,48 @@ public EncounterService(TradeWinds addon) { public void start() { long period = Math.max(1, addon.getSettings().getEncounterCheckSeconds()) * 20L; task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::tick, period, period); + // Mobs forget, and a passenger cannot fight: keep them hunting + aggression = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::hunt, 40L, 40L); } public void stop() { if (task != null) { task.cancel(); } + if (aggression != null) { + aggression.cancel(); + } + } + + /** + * Keep encounters dangerous: re-assert targets (mobs lose interest, and a + * freshly spawned target is forgotten within moments) and put crews over + * the side when their quarry is close - a witch or pillager riding a boat + * cannot run its attack goals at all, which made the sea witch a harmless + * ornament. + */ + private void hunt() { + if (addon.getOverWorld() == null) { + return; + } + for (Player player : addon.getOverWorld().getPlayers()) { + if (player.getGameMode() != org.bukkit.GameMode.SURVIVAL || player.isDead()) { + continue; + } + for (Entity entity : player.getNearbyEntities(HUNT_RANGE, 32, HUNT_RANGE)) { + if (!entity.getPersistentDataContainer().has(ENCOUNTER_KEY, PersistentDataType.STRING) + || !(entity instanceof Mob mob)) { + continue; + } + if (mob.getTarget() == null || mob.getTarget().isDead()) { + mob.setTarget(player); + } + if (mob.getVehicle() instanceof Boat + && mob.getLocation().distanceSquared(player.getLocation()) < BOARDING_RANGE * BOARDING_RANGE) { + mob.leaveVehicle(); + } + } + } } private void tick() { From aa3c88dcea262756d5a068154e58d5fd4861c031 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 19:44:42 -0700 Subject: [PATCH 043/112] Crews abandon ship on sighting, not just at close range Ben reported the sea witch does throw potions but they sail over his head into the water - the same root cause as her looking inert: a mob riding a boat cannot reposition, so her arc is fixed from a drifting platform. Widened the abandon-ship range to 30 blocks (beyond the 28-block spawn distance) so crews disembark as soon as they sight their quarry and can then swim, close and aim normally. 149 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 5 +++-- docs/PROGRESS.md | 8 ++++++-- .../bentobox/tradewinds/encounters/EncounterService.java | 9 +++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/TESTING.md b/TESTING.md index 1837a1a..8781bb9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -397,8 +397,9 @@ temporarily to force it. - ~~The witch adrift did nothing and was easy to kill~~ FIXED: mobs riding a boat cannot run their attack goals, and the target was set once at spawn. An aggression pass now re-asserts targets every 2s, and boated crews - ABANDON SHIP when you come within ~14 blocks — pirates and the witch should - now come over the side at you. + ABANDON SHIP as soon as they sight you (~30 blocks, wider than the spawn + distance) — a mob that cannot reposition lobs its potions over your head. + The abandoned boat stays behind: salvage it if you want. - [ ] Safe island waters are quiet (2% base, further reduced near the dock); encounters intensify with distance from any island. - [ ] Killing encounter mobs sometimes drops booty (nautilus shells, tridents, diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 064fcd3..a0fc9a7 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -11,8 +11,12 @@ PIRATE_CREW - were ornaments), and the target was set once at spawn with nothing to re-assert it, so any mob that lost interest stayed lost. EncounterService now runs a 2s aggression pass over encounter-tagged mobs near players: re-target when the target is gone, and ABANDON SHIP (leaveVehicle) -when the quarry is within 14 blocks - crews come over the side to fight, -which is both functional and the right image. This also stiffens the swimming +when the quarry is within 30 blocks - wider than the 28-block spawn distance, +so crews disembark as soon as they sight you. Ben then reported the witch +DOES throw potions but they sail over his head into the sea, which is the +same root cause: a passenger cannot reposition, so her arc is fixed from a +drifting platform. Swimming, she closes and aims normally. The abandoned +boat is left floating - salvage for the victor. This also stiffens the swimming encounters (drowned, nautilus, guardians), which previously forgot their quarry after a short chase. diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java index 07288cb..fdff109 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java @@ -39,8 +39,13 @@ public class EncounterService { /** PDC key marking encounter mobs (booty, cleanup). */ public static final NamespacedKey ENCOUNTER_KEY = NamespacedKey.fromString("tradewinds:encounter"); - /** How close before a boated crew abandons ship to attack. */ - private static final double BOARDING_RANGE = 14.0; + /** + * How close before a boated crew abandons ship. Wider than the spawn + * distance, so crews disembark as soon as they sight their quarry: a mob + * riding a boat cannot reposition, and a witch lobbing potions from a + * fixed drifting platform simply throws them over the target's head. + */ + private static final double BOARDING_RANGE = 30.0; /** How far encounter mobs keep hunting. */ private static final double HUNT_RANGE = 48.0; From 8d7f99a84519e1cafdd09b34f5a96036d27dbd73 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 19:55:18 -0700 Subject: [PATCH 044/112] Vary the open sea through the ocean biomes, seeded and gradual The ocean was a single flat OCEAN biome. A seeded temperature field (new pure Noise helper: smoothstep value noise on a 3000-block lattice) now maps to the five ocean biomes in temperature order, so voyages cross frozen, cold, ordinary, lukewarm and warm water. Blending needs no special pass: a continuous field mapped in temperature order can only step one place at a time, so warm sea never borders frozen. A test walks 80,000 blocks asserting the temperature never skips a step, and that a voyage crosses at least three seas. world.vary-ocean-biomes disables it. 152 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 9 ++++ docs/PROGRESS.md | 17 ++++++ .../world/bentobox/tradewinds/Settings.java | 9 ++++ .../tradewinds/galaxy/GalaxyEngine.java | 37 +++++++++++++ .../bentobox/tradewinds/galaxy/Noise.java | 52 +++++++++++++++++++ .../generator/TradeWindsBiomeProvider.java | 19 +++++-- src/main/resources/config.yml | 4 ++ .../tradewinds/galaxy/GalaxyEngineTest.java | 35 +++++++++++++ .../TradeWindsBiomeProviderTest.java | 27 ++++++++-- 9 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/Noise.java diff --git a/TESTING.md b/TESTING.md index 8781bb9..4380d85 100644 --- a/TESTING.md +++ b/TESTING.md @@ -502,3 +502,12 @@ database (or use `/twadmin delete` on it), then restart. - [ ] Opening the chart at sea (not at an island) does NOT scan — discovery still requires making landfall. - [ ] `chart.port-scan: 0` disables the free scan. + +### Stage 5i — varied ocean biomes +**Needs fresh chunks** (biomes are baked at generation). +- [ ] Sail a long way through open water: the sea changes through frozen, cold, + ordinary, lukewarm and warm ocean — water colour, fog and fish change with it. +- [ ] Transitions are gradual: you never find warm water directly against frozen. + (A test walks 80,000 blocks asserting the temperature never skips a step.) +- [ ] The same seed gives the same seas; a different seed gives different ones. +- [ ] `world.vary-ocean-biomes: false` returns the world to a single ocean biome. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a0fc9a7..94a6b92 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,23 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Varied ocean biomes (2026-07-31) + +The open sea was a single flat OCEAN everywhere. It now varies through the +five ocean biomes from a seeded temperature field: a new pure `Noise` helper +(smoothstep value noise over a 3000-block lattice, seeded like everything +else) drives `GalaxyEngine.oceanBiomeKeyAt`. +Ben's blending worry has a structural answer rather than a blending pass: +because the field is CONTINUOUS and the biomes are mapped in TEMPERATURE +ORDER (frozen, cold, ocean, lukewarm, warm), neighbouring water can only +differ by one step - warm can never border frozen. A test walks 80,000 blocks +asserting the index never jumps by more than one, and that a voyage crosses +at least three different seas. +The provider declares all ocean biomes in getBiomes; `world.vary-ocean-biomes` +turns it off. FROZEN islands keep their frozen-ocean approach ring, which now +sits naturally inside cold water where the field allows. +152 tests green. + ## Playtest fix: the harmless sea witch (2026-07-31) The witch adrift did nothing and died easily. Two causes: a mob riding a boat diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 66db50d..fc052c0 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -628,6 +628,13 @@ private static Map defaultEncounterChance() { @ConfigEntry(path = "world.default-game-mode") private GameMode defaultGameMode = GameMode.SURVIVAL; + @ConfigComment("Vary the open sea through the ocean biomes (frozen, cold, ocean, lukewarm,") + @ConfigComment("warm) using a seeded temperature field, so voyages cross visibly different") + @ConfigComment("water. Transitions are always gradual - warm sea never borders frozen sea.") + @ConfigComment("False uses the single default-biome everywhere.") + @ConfigEntry(path = "world.vary-ocean-biomes") + private boolean varyOceanBiomes = true; + @ConfigComment("The default biome for the ocean (at and below sea level)") @ConfigEntry(path = "world.default-biome") private Biome defaultBiome; @@ -2541,6 +2548,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setIntersticeSeaFloor(int intersticeSeaFloor) { this.intersticeSeaFloor = intersticeSeaFloor; } public Material getIntersticeWaterBlock() { return intersticeWaterBlock == null ? Material.WATER : intersticeWaterBlock; } public void setIntersticeWaterBlock(Material intersticeWaterBlock) { this.intersticeWaterBlock = intersticeWaterBlock; } + public boolean isVaryOceanBiomes() { return varyOceanBiomes; } + public void setVaryOceanBiomes(boolean varyOceanBiomes) { this.varyOceanBiomes = varyOceanBiomes; } public Biome getDefaultAirBiome() { return defaultAirBiome == null ? Biome.OCEAN : defaultAirBiome; } public void setDefaultAirBiome(Biome defaultAirBiome) { this.defaultAirBiome = defaultAirBiome; } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index 03a4f4d..752ac52 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -44,6 +44,21 @@ public class GalaxyEngine { private static final long SALT_WILD_X = 0x317DBEA8L; private static final long SALT_WILD_Z = 0x317DBEA9L; private static final long SALT_WILD_BIOME = 0x317DBEAAL; + private static final long SALT_OCEAN_TEMP = 0x0CEA17E1L; + + /** + * Scale of the ocean's temperature regions, in blocks. Broad enough that a + * voyage crosses a few of them. + */ + private static final int OCEAN_TEMPERATURE_SCALE = 3000; + + /** + * Ocean biomes in temperature order. Because the temperature field is + * continuous and this mapping is monotonic, neighbouring water can only + * differ by one step - warm sea never borders frozen sea. + */ + private static final List OCEAN_BIOMES = List.of("minecraft:frozen_ocean", "minecraft:cold_ocean", + "minecraft:ocean", "minecraft:lukewarm_ocean", "minecraft:warm_ocean"); /** Vanilla biomes wild islets draw from - Minecraft-stuff land. */ private static final List WILD_BIOMES = List.of("minecraft:plains", "minecraft:forest", @@ -406,6 +421,28 @@ public Optional columnPlanAt(int blockX, int blockZ) { return Optional.empty(); } + /** + * The open sea's biome at a position: a seeded temperature field mapped to + * the ocean biomes in temperature order, so the water varies from frozen + * through to warm as you sail, and always gradually. + * + * @param blockX block x + * @param blockZ block z + * @return an ocean biome key + */ + public String oceanBiomeKeyAt(int blockX, int blockZ) { + double temperature = Noise.at(config.seed(), SALT_OCEAN_TEMP, blockX, blockZ, OCEAN_TEMPERATURE_SCALE); + int index = Math.clamp((int) (temperature * OCEAN_BIOMES.size()), 0, OCEAN_BIOMES.size() - 1); + return OCEAN_BIOMES.get(index); + } + + /** + * Every ocean biome the sea can take - the world must declare them all. + */ + public static List oceanBiomes() { + return OCEAN_BIOMES; + } + /** * The biome key governing a column, or empty for default ocean handling. * Within an island's terrain radius this is the island biome; in the diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/Noise.java b/src/main/java/world/bentobox/tradewinds/galaxy/Noise.java new file mode 100644 index 0000000..a3fba17 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/Noise.java @@ -0,0 +1,52 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Smooth value noise over a seeded lattice. Pure maths, no Bukkit: the ocean's + * temperature field is built from this, so it is deterministic from the galaxy + * seed like everything else. + * + * @author tastybento + */ +public final class Noise { + + private Noise() { + // Static use only + } + + /** + * Smooth noise in [0, 1] at a world position. + * + * @param seed galaxy seed + * @param salt distinguishes independent fields + * @param x world x + * @param z world z + * @param lattice spacing of the lattice in blocks - the scale of the + * features; larger means broader, slower-changing regions + * @return value in [0, 1] + */ + public static double at(long seed, long salt, double x, double z, int lattice) { + double gx = x / lattice; + double gz = z / lattice; + int x0 = (int) Math.floor(gx); + int z0 = (int) Math.floor(gz); + double fx = smoothstep(gx - x0); + double fz = smoothstep(gz - z0); + double v00 = corner(seed, salt, x0, z0); + double v10 = corner(seed, salt, x0 + 1, z0); + double v01 = corner(seed, salt, x0, z0 + 1); + double v11 = corner(seed, salt, x0 + 1, z0 + 1); + return lerp(lerp(v00, v10, fx), lerp(v01, v11, fx), fz); + } + + private static double corner(long seed, long salt, int x, int z) { + return Hashing.toUnit(Hashing.cellHash(seed, x, z, salt)); + } + + private static double smoothstep(double t) { + return t * t * (3 - 2 * t); + } + + private static double lerp(double a, double b, double t) { + return a + (b - a) * t; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java b/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java index a9f2920..8f81d9f 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java +++ b/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java @@ -13,6 +13,7 @@ import org.bukkit.generator.WorldInfo; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandType; /** @@ -39,10 +40,16 @@ public Biome getBiome(WorldInfo worldInfo, int x, int y, int z) { if (worldInfo.getEnvironment() == org.bukkit.World.Environment.NETHER) { return addon.getSettings().getDefaultNetherBiome(); } - Optional key = addon.getGalaxyEngine(worldInfo.getSeed()).biomeKeyAt(x, z); + GalaxyEngine engine = addon.getGalaxyEngine(worldInfo.getSeed()); + Optional key = engine.biomeKeyAt(x, z); if (key.isPresent()) { return resolve(key.get()); } + if (addon.getSettings().isVaryOceanBiomes()) { + // The open sea, varying from frozen to warm with the seeded + // temperature field + return resolve(engine.oceanBiomeKeyAt(x, z)); + } return y <= addon.getSettings().getSeaHeight() ? addon.getSettings().getDefaultBiome() : addon.getSettings().getDefaultAirBiome(); @@ -82,10 +89,12 @@ public List getBiomes(WorldInfo worldInfo) { } }); } - Biome frozen = resolve("minecraft:frozen_ocean"); - if (!biomes.contains(frozen)) { - biomes.add(frozen); - } + GalaxyEngine.oceanBiomes().forEach(key -> { + Biome b = resolve(key); + if (!biomes.contains(b)) { + biomes.add(b); + } + }); return biomes; } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b6c39e1..7c9fda5 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -277,6 +277,10 @@ world: sea-height: 70 # Base y below which the ocean floor terrain noise starts. sea-floor: 25 + # Vary the open sea through the ocean biomes (frozen, cold, ocean, lukewarm, warm) + # from a seeded temperature field. Transitions are gradual: warm sea never borders + # frozen sea. False uses default-biome everywhere. + vary-ocean-biomes: true # Water block for the overworld ocean. water-block: WATER # Allow vanilla cave generation under the ocean floor. diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index 9f0ffb0..7b81c3d 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -307,6 +307,41 @@ void testIsletsAreFindable() { } } + @Test + void testOceanBiomesVaryButNeverJump() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.5)); + java.util.List order = GalaxyEngine.oceanBiomes(); + java.util.Set seen = new java.util.HashSet<>(); + int previous = -1; + // Sail a long line, sampling every 50 blocks + for (int x = -40_000; x <= 40_000; x += 50) { + String key = engine.oceanBiomeKeyAt(x, 12_345); + int index = order.indexOf(key); + assertTrue(index >= 0, "Unknown ocean biome: " + key); + seen.add(key); + if (previous >= 0) { + assertTrue(Math.abs(index - previous) <= 1, + "Ocean temperature jumped from " + order.get(previous) + " to " + key + " at x=" + x); + } + previous = index; + } + // The voyage crosses genuinely different water + assertTrue(seen.size() >= 3, "Ocean is too uniform: only " + seen); + } + + @Test + void testOceanBiomesAreSeeded() { + GalaxyEngine a = new GalaxyEngine(config(SEED, 0.5)); + GalaxyEngine b = new GalaxyEngine(config(SEED, 0.5)); + GalaxyEngine other = new GalaxyEngine(config(SEED + 1, 0.5)); + boolean differs = false; + for (int x = 0; x < 20_000; x += 500) { + assertEquals(a.oceanBiomeKeyAt(x, 0), b.oceanBiomeKeyAt(x, 0)); + differs |= !a.oceanBiomeKeyAt(x, 0).equals(other.oceanBiomeKeyAt(x, 0)); + } + assertTrue(differs, "A different seed should give a different sea"); + } + @Test void testNamesAreDistinctEnough() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); diff --git a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java index 37fad4d..4f86682 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java @@ -59,13 +59,30 @@ private WorldInfo worldInfo(Environment env) { } @Test - void testOpenOceanBiomes() { + void testOpenOceanVariesThroughOceanBiomes() { // Sampled far from the origin - the spawn island sits at 0,0 WorldInfo wi = worldInfo(Environment.NORMAL); - int x = 100_000; - assertEquals(Biome.OCEAN, provider.getBiome(wi, x, settings.getSeaHeight(), x)); - assertEquals(Biome.OCEAN, provider.getBiome(wi, x, 10, x)); - assertEquals(settings.getDefaultAirBiome(), provider.getBiome(wi, x, settings.getSeaHeight() + 1, x)); + java.util.Set seen = new java.util.HashSet<>(); + java.util.Set oceans = GalaxyEngine.oceanBiomes().stream() + .map(key -> org.bukkit.Registry.BIOME.get(org.bukkit.NamespacedKey.fromString(key))) + .collect(java.util.stream.Collectors.toSet()); + for (int x = 100_000; x < 140_000; x += 500) { + Biome biome = provider.getBiome(wi, x, settings.getSeaHeight(), 12_345); + assertTrue(oceans.contains(biome), "Open sea should be an ocean biome, got " + biome.getKey()); + // The same column above water reads the same sea + assertEquals(biome, provider.getBiome(wi, x, settings.getSeaHeight() + 1, 12_345)); + seen.add(biome); + } + assertTrue(seen.size() > 1, "The open sea should vary"); + } + + @Test + void testUniformOceanWhenVaryingIsOff() { + settings.setVaryOceanBiomes(false); + WorldInfo wi = worldInfo(Environment.NORMAL); + assertEquals(Biome.OCEAN, provider.getBiome(wi, 100_000, settings.getSeaHeight(), 100_000)); + assertEquals(settings.getDefaultAirBiome(), + provider.getBiome(wi, 100_000, settings.getSeaHeight() + 1, 100_000)); } @Test From 46962a32ad83bb3fbff6b2eb87806049b56be105 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 19:59:28 -0700 Subject: [PATCH 045/112] Encounters: real monsters, spawned in their element The zombie nautilus swam away because it is not a monster at all: 26.2's AbstractNautilus extends Tameable, InventoryHolder and Vehicle - a rideable sea mount with no attack goals. Replaced it with an elder guardian as the deep-water encounter, and added a test asserting every encounter mob implements Enemy so scenery cannot be rostered as a threat again. Water mobs were also spawning above the waterline, where swimmers flop instead of hunting. Encounters now declare a habitat: swimmers 3 blocks under, phantoms 14 above, boated crews on the surface. Encounter spawns are logged when debug is enabled. 154 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 13 ++++- docs/PROGRESS.md | 21 +++++++ .../encounters/EncounterService.java | 10 +++- .../tradewinds/encounters/EncounterType.java | 56 ++++++++++++++++--- src/main/resources/locales/en-US.yml | 2 +- .../encounters/EncounterTableTest.java | 31 +++++++++- 6 files changed, 117 insertions(+), 16 deletions(-) diff --git a/TESTING.md b/TESTING.md index 4380d85..8e63517 100644 --- a/TESTING.md +++ b/TESTING.md @@ -391,9 +391,16 @@ temporarily to force it. - [ ] Row in open water far from islands: within a few rolls (45s each) mobs appear ~28 blocks AHEAD of you — visible, so fleeing is a real choice. - [ ] Day vs night differ: guardians by day, trident-throwing drowned at night. -- [ ] Lawless/anarchic water adds worse things: Zombie Nautilus (26.2's undead sea - horror), phantoms at night, pillager PIRATE CREWS in their own boat, and the - sea witch. +- [ ] Lawless/anarchic water adds worse things: an ELDER GUARDIAN in deep water + (flee — it is meant to be unwinnable for a lone trader), phantoms at night, + pillager PIRATE CREWS in their own boat, and the sea witch. + - ~~The zombie nautilus just swam away~~ FIXED: it is a tameable MOUNT, not a + monster (AbstractNautilus extends Tameable/Vehicle), so it was never going to + attack. Replaced with the elder guardian; a test now asserts every encounter + mob implements Enemy. + - Water mobs also used to spawn ABOVE the waterline, where they flop instead of + hunting. They now spawn 3 blocks under, phantoms 14 above, boats on the + surface. - ~~The witch adrift did nothing and was easy to kill~~ FIXED: mobs riding a boat cannot run their attack goals, and the target was set once at spawn. An aggression pass now re-asserts targets every 2s, and boated crews diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 94a6b92..3a21cac 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,27 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The nautilus is a mount, and swimmers were spawning in the air (2026-07-31) + +Ben reported the "sea horror" encounter swimming harmlessly away - and he had +seen our encounter message, so it was ours. Two causes: +- **ZOMBIE_NAUTILUS is not a monster.** 26.2's `AbstractNautilus extends + Tameable, InventoryHolder, Vehicle` - it is a rideable, tameable sea mount + with an inventory, so it has no attack goals at all. Replaced with an + ELDER_GUARDIAN as the FRONTIER+ deep-water encounter (a flee-not-fight + threat), and a test now asserts every encounter mob implements + `org.bukkit.entity.Enemy`, so scenery can never be rostered as a threat + again. +- **Water mobs were spawning above the waterline** (seaHeight + 1.5), where a + swimmer flops instead of hunting. EncounterType gained a Habitat: swimmers + spawn 3 blocks under water, phantoms 14 above, boated crews on the surface. +Also added debug logging of encounter spawns (gated by `debug:`) so "was that +one of ours?" is answerable from the console. + +NOTE for post-MVP: a tameable sea mount with an inventory is a gift for this +game mode - nautilus as an alternative to boats, or a late-game hold. Worth +its own feature. + ## Varied ocean biomes (2026-07-31) The open sea was a single flat OCEAN everywhere. It now varies through the diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java index fdff109..2f3bc40 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java @@ -166,7 +166,9 @@ public void spawn(Player player, EncounterType type) { } ahead.normalize().multiply(addon.getSettings().getEncounterDistance()); Location spot = origin.clone().add(ahead); - spot.setY(addon.getSettings().getSeaHeight() + 1.0); + // Swimmers below the waterline, fliers above it: a water mob spawned in + // the air just flops about instead of hunting + spot.setY(type.getHabitat().spawnY(addon.getSettings().getSeaHeight())); int count = type.getMin() + (int) (Math.random() * Math.max(1, type.getMax() - type.getMin() + 1)); @@ -180,7 +182,7 @@ public void spawn(Player player, EncounterType type) { spawned.add(crewBoat); } for (EntityType mobType : type.getMobs()) { - Entity entity = at.getWorld().spawnEntity(type.isBoated() ? at : at.clone().add(0, 0.5, 0), mobType); + Entity entity = at.getWorld().spawnEntity(at, mobType); equip(entity, mobType); tag(entity); if (entity instanceof Mob mob) { @@ -198,6 +200,10 @@ public void spawn(Player player, EncounterType type) { } User.getInstance(player).sendMessage("tradewinds.encounter." + type.name().toLowerCase(java.util.Locale.ENGLISH)); player.playSound(player.getLocation(), Sound.AMBIENT_UNDERWATER_LOOP_ADDITIONS_RARE, 1.0f, 0.6f); + if (addon.getSettings().isDebug()) { + addon.log("[DEBUG] " + type + " x" + spawned.size() + " for " + player.getName() + " at " + + spot.getBlockX() + "," + spot.getBlockY() + "," + spot.getBlockZ()); + } } /** diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java index 057d6d2..38fe008 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java @@ -16,17 +16,50 @@ public enum EncounterType { /** Slow swimmers that block the way ahead rather than chase - daytime nuisance. */ - GUARDIAN_PICKET(List.of(EntityType.GUARDIAN), Time.DAY, SecurityBand.POLICED, 1, 2, false), + GUARDIAN_PICKET(List.of(EntityType.GUARDIAN), Time.DAY, SecurityBand.POLICED, 1, 2, false, Habitat.WATER), /** Trident throwers: the real danger to a boat, after dark. */ - DROWNED_RAIDERS(List.of(EntityType.DROWNED), Time.NIGHT, SecurityBand.POLICED, 2, 3, false), - /** 26.2's undead sea horror - the deep water signature. */ - NAUTILUS_HORROR(List.of(EntityType.ZOMBIE_NAUTILUS), Time.ANY, SecurityBand.FRONTIER, 1, 2, false), + DROWNED_RAIDERS(List.of(EntityType.DROWNED), Time.NIGHT, SecurityBand.POLICED, 2, 3, false, Habitat.WATER), + /** + * The deep stirs: an elder guardian, far too much for a lone sailor to + * fight lightly - the encounter where fleeing is the sensible answer. + * (26.2's nautilus, zombie or not, is a tameable MOUNT, not a monster: it + * simply swims away.) + */ + DEEP_TERROR(List.of(EntityType.ELDER_GUARDIAN), Time.ANY, SecurityBand.FRONTIER, 1, 1, false, Habitat.WATER), /** The sky is not safe either. */ - PHANTOM_FLIGHT(List.of(EntityType.PHANTOM), Time.NIGHT, SecurityBand.FRONTIER, 2, 3, false), + PHANTOM_FLIGHT(List.of(EntityType.PHANTOM), Time.NIGHT, SecurityBand.FRONTIER, 2, 3, false, Habitat.AIR), /** A crewed boat of raiders: NPC piracy, and a warning of the player kind. */ - PIRATE_CREW(List.of(EntityType.PILLAGER, EntityType.PILLAGER), Time.ANY, SecurityBand.LAWLESS, 1, 1, true), + PIRATE_CREW(List.of(EntityType.PILLAGER, EntityType.PILLAGER), Time.ANY, SecurityBand.LAWLESS, 1, 1, true, + Habitat.SURFACE), /** A potion-throwing witch adrift in her own boat. */ - SEA_WITCH(List.of(EntityType.WITCH), Time.ANY, SecurityBand.LAWLESS, 1, 1, true); + SEA_WITCH(List.of(EntityType.WITCH), Time.ANY, SecurityBand.LAWLESS, 1, 1, true, Habitat.SURFACE); + + /** + * Where an encounter belongs relative to the waterline. Spawning a water + * mob in the air above the sea leaves it flopping instead of hunting. + */ + public enum Habitat { + /** Below the surface, where swimmers can actually swim. */ + WATER(-3), + /** On the waterline, for boats and their crews. */ + SURFACE(1), + /** Above the mast. */ + AIR(14); + + private final int offset; + + Habitat(int offset) { + this.offset = offset; + } + + /** + * @param seaLevel the world sea level + * @return the Y to spawn at + */ + public int spawnY(int seaLevel) { + return seaLevel + offset; + } + } /** * When an encounter hunts. @@ -45,14 +78,21 @@ public boolean matches(boolean isDay) { private final int min; private final int max; private final boolean boated; + private final Habitat habitat; - EncounterType(List mobs, Time time, SecurityBand minBand, int min, int max, boolean boated) { + EncounterType(List mobs, Time time, SecurityBand minBand, int min, int max, boolean boated, + Habitat habitat) { this.mobs = mobs; this.time = time; this.minBand = minBand; this.min = min; this.max = max; this.boated = boated; + this.habitat = habitat; + } + + public Habitat getHabitat() { + return habitat; } public List getMobs() { diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 54b2247..37fa141 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -80,7 +80,7 @@ tradewinds: encounter: guardian_picket: "&3 Guardians rise in your path." drowned_raiders: "&3 Drowned surface off the bow - some carry tridents." - nautilus_horror: "&5 Something vast and dead uncoils beneath the hull." + deep_terror: "&5 Something vast and ancient rises from the deep. Run." phantom_flight: "&3 Wings in the dark above the mast." pirate_crew: "&c A crewed boat turns toward you. Pirates." sea_witch: "&5 A witch adrift, and she has seen you." diff --git a/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java b/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java index 346f947..ff2d0f7 100644 --- a/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java +++ b/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java @@ -55,16 +55,43 @@ void testDayAndNightRosters() { @Test void testLawlessnessUnlocksWorseThings() { Set policed = rollAll(SecurityBand.POLICED, true); - assertFalse(policed.contains(EncounterType.NAUTILUS_HORROR)); + assertFalse(policed.contains(EncounterType.DEEP_TERROR)); assertFalse(policed.contains(EncounterType.PIRATE_CREW)); Set frontier = rollAll(SecurityBand.FRONTIER, true); - assertTrue(frontier.contains(EncounterType.NAUTILUS_HORROR)); + assertTrue(frontier.contains(EncounterType.DEEP_TERROR)); assertFalse(frontier.contains(EncounterType.PIRATE_CREW)); Set anarchic = rollAll(SecurityBand.ANARCHIC, true); assertTrue(anarchic.contains(EncounterType.PIRATE_CREW)); assertTrue(anarchic.contains(EncounterType.SEA_WITCH)); } + @Test + void testMobsSpawnInTheirElement() { + // A swimmer spawned above the waterline just flops; a flier below it drowns + assertTrue(EncounterType.DROWNED_RAIDERS.getHabitat().spawnY(70) < 70, + "Swimmers must spawn under water"); + assertTrue(EncounterType.DEEP_TERROR.getHabitat().spawnY(70) < 70); + assertTrue(EncounterType.GUARDIAN_PICKET.getHabitat().spawnY(70) < 70); + assertTrue(EncounterType.PHANTOM_FLIGHT.getHabitat().spawnY(70) > 70 + 5, + "Phantoms belong above the mast"); + assertEquals(71, EncounterType.SEA_WITCH.getHabitat().spawnY(70), "Boats float on the surface"); + assertEquals(71, EncounterType.PIRATE_CREW.getHabitat().spawnY(70)); + } + + @Test + void testEveryEncounterMobIsActuallyHostile() { + // The zombie nautilus taught this lesson: it is a tameable MOUNT + // (AbstractNautilus extends Tameable, Vehicle), so it just swam away. + // Every encounter mob must be an Enemy or the encounter is scenery. + for (EncounterType type : EncounterType.values()) { + for (org.bukkit.entity.EntityType mob : type.getMobs()) { + Class clazz = mob.getEntityClass(); + assertTrue(clazz != null && org.bukkit.entity.Enemy.class.isAssignableFrom(clazz), + type + " spawns " + mob + ", which is not hostile"); + } + } + } + @Test void testCrewsArriveByBoat() { assertTrue(EncounterType.PIRATE_CREW.isBoated()); From 1942293157d28a249d46a45acbfb60fa559628b1 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 20:02:11 -0700 Subject: [PATCH 046/112] Convert the locale to MiniMessage MiniMessage is the BentoBox standard, so en-US.yml now uses closed tags (text) in core's own style rather than legacy ampersand codes. ChartingListener wrapped a translated string in Component.text for the action bar, which would have printed tags literally - it now deserializes with MiniMessage. Convention recorded in CLAUDE.md, including the rule for Component-taking sinks. 154 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 7 ++ docs/PROGRESS.md | 10 ++ .../tradewinds/travel/ChartingListener.java | 6 +- src/main/resources/locales/en-US.yml | 99 ++++++++++--------- 4 files changed, 72 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 95a4c64..860945f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,3 +74,10 @@ Follow BentoBox conventions: `Config` + `@ConfigEntry`/`@StoreAt`, `Database` + cache managers, `FlagListener`, `DefaultPlayerCommand` /`DefaultAdminCommand`, locale keys under `tradewinds.`, every gameplay number in config from the stage it's introduced — no hardcoded gameplay values. + +**Locale formatting is MiniMessage** (`text`, ``) — +the BentoBox standard; legacy `&` codes are deprecated, never add new ones. +When sending a translated string somewhere that takes a Component (action +bars, titles, holograms), deserialize it with +`MiniMessage.miniMessage().deserialize(...)` rather than `Component.text(...)`, +or the tags print literally. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3a21cac..7e5e7bd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,16 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Locale converted to MiniMessage (2026-07-31) + +Ben: MiniMessage is the standard from now on. en-US.yml converted from legacy +ampersand codes to closed MiniMessage tags, matching core's own locale style +(`text`). Also fixed ChartingListener, which wrapped a translated +string in `Component.text` for the action bar - that would have printed the +tags literally; it now deserializes with MiniMessage. Convention recorded in +CLAUDE.md: locale strings are MiniMessage, and any Component-taking sink +(action bars, titles, holograms) must deserialize rather than wrap. + ## The nautilus is a mount, and swimmers were spawning in the air (2026-07-31) Ben reported the "sea horror" encounter swimming harmlessly away - and he had diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java index 409fe67..bf9f7ae 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java @@ -10,7 +10,7 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.vehicle.VehicleEnterEvent; -import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.api.events.IslandChartedEvent; @@ -80,7 +80,9 @@ public void onMove(PlayerMoveEvent event) { .filter(data::chart) .forEach(spec -> { User user = User.getInstance(player); - player.sendActionBar(Component.text( + // Locale strings are MiniMessage: deserialize rather than + // wrapping raw text, or the tags would print literally + player.sendActionBar(MiniMessage.miniMessage().deserialize( user.getTranslation("tradewinds.chart.charted", "[name]", spec.name()))); addon.getPlayerDataManager().save(player.getUniqueId()); Bukkit.getPluginManager().callEvent(new IslandChartedEvent(player.getUniqueId(), spec)); diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 37fa141..10cfe7e 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -2,6 +2,9 @@ ########################################################################################### # This is a YML file. Be careful when editing. Check your edits in a YAML checker like # # the one at http://yaml-online-parser.appspot.com # +# # +# Colour and formatting use MiniMessage tags: text, text and so # +# on. Legacy ampersand codes are deprecated - do not add new ones. # ########################################################################################### tradewinds: @@ -29,66 +32,66 @@ tradewinds: description: "admin command for TradeWinds" islands: description: "list the nearest trading islands" - none: "&c No trading islands found - is the galaxy density 0?" - header: "&b Nearest trading islands (use &e/twadmin tpisland <#>&b to visit):" - entry: "&e [index]. &f[name] &7- [type], [band] &7at &f[x], [z] &7([distance] blocks)" + none: "No trading islands found - is the galaxy density 0?" + header: "Nearest trading islands (use /twadmin tpisland <#> to visit):" + entry: "[index]. [name] - [type], [band] at [x], [z] ([distance] blocks)" tpisland: description: "teleport to a trading island from the islands list" parameters: "[number]" - unknown: "&c No island with that number - run /twadmin islands first." - teleported: "&b Arrived at [name]." + unknown: "No island with that number - run /twadmin islands first." + teleported: "Arrived at [name]." reflag: description: "re-apply security-band flags to all trading islands" - done: "&a Re-applied band flags to [number] trading islands." + done: "Re-applied band flags to [number] trading islands." spawn: - teleported: "&b You set sail on the open ocean." + teleported: "You set sail on the open ocean." starter-kit: - given: "&6 Your trading career begins: a boat, a trading bundle, and a little coal. Buy low, sell high." + given: "Your trading career begins: a boat, a trading pouch, and a little coal. Buy low, sell high." status: clean: "Clean" chart: - charted: "Charted [name]!" - port-scan: "&b You copy the harbour charts: [number] islands added to yours." - holograms-shown: "&b Your chart shimmers into the air - row toward a name." - starchart-given: "&b The Star Chart is yours - hold it to see your world." - empty: "&c Your chart is empty - row into an island's waters to chart it." - header: "&b Your chart ([number] islands):" - entry: "&e [name] &7- [type], [band], [distance] blocks" + charted: "Charted [name]!" + port-scan: "You copy the harbour charts: [number] islands added to yours." + holograms-shown: "Your chart shimmers into the air - row toward a name." + starchart-given: "The Star Chart is yours - hold it to see your world." + empty: "Your chart is empty - row into an island's waters to chart it." + header: "Your chart ([number] islands):" + entry: "[name] - [type], [band], [distance] blocks" restart: - confirm: "&c Restart your career? Balance and kit reset; your chart is kept." - none-left: "&c No restarts left (maximum [number])." - done: "&6 A fresh start. Restarts remaining: [number]." + confirm: "Restart your career? Balance and kit reset; your chart is kept." + none-left: "No restarts left (maximum [number])." + done: "A fresh start. Restarts remaining: [number]." trade: - not-at-market: "&c There is no market here - trade within an island's protection range." - not-stamped: "&c Traders only buy customs-stamped goods (or... certain other merchandise)." - nothing-to-sell: "&c Nothing in your hold that this island will pay for." - nothing-for-sale: "&c This island has nothing for sale - it only buys." - sold: "&a Sold [amount] x [material] for $[price]." - bought: "&a Bought [amount] x [material] for $[price]." - cannot-afford: "&c You cannot afford that." - no-hold-space: "&c No room in your hold - stow cargo in your chest boat, expanders or bundles." - needs-chest-boat: "&c Expanders are stowed in a chest boat - buy one from the shipwright first." - expander-bought: "&6 Cargo expander purchased for $[price] - carried in your pack." - pouch-bought: "&6 Trading pouch purchased for $[price]." - pouch-cap: "&c You cannot carry any more trading pouches." - charity-given: "&a The harbourmaster hands you a pouch. 'Get back to work, sailor.'" - charity-not-needed: "&c The harbourmaster looks you over. 'You'll manage.'" - charity-cooldown: "&c 'I helped you already. Come back in [number] minutes.'" - expander-cap: "&c Your boat cannot take any more expanders." + not-at-market: "There is no market here - trade within an island's protection range." + not-stamped: "Traders only buy customs-stamped goods (or... certain other merchandise)." + nothing-to-sell: "Nothing in your hold that this island will pay for." + nothing-for-sale: "This island has nothing for sale - it only buys." + sold: "Sold [amount] x [material] for $[price]." + bought: "Bought [amount] x [material] for $[price]." + cannot-afford: "You cannot afford that." + no-hold-space: "No room in your hold - stow cargo in a pouch, an expander or your chest boat." + needs-chest-boat: "Expanders are stowed in a chest boat - buy one from the shipwright first." + expander-bought: "Cargo expander purchased for $[price] - carried in your pack." + pouch-bought: "Trading pouch purchased for $[price]." + pouch-cap: "You cannot carry any more trading pouches." + charity-given: "The harbourmaster hands you a pouch. 'Get back to work, sailor.'" + charity-not-needed: "The harbourmaster looks you over. 'You'll manage.'" + charity-cooldown: "'I helped you already. Come back in [number] minutes.'" + expander-cap: "Your boat cannot take any more expanders." interstice: - stranded: "&4 The warp collapses! Dark water, and screaming above it." + stranded: "The warp collapses! Dark water, and screaming above it." encounter: - guardian_picket: "&3 Guardians rise in your path." - drowned_raiders: "&3 Drowned surface off the bow - some carry tridents." - deep_terror: "&5 Something vast and ancient rises from the deep. Run." - phantom_flight: "&3 Wings in the dark above the mast." - pirate_crew: "&c A crewed boat turns toward you. Pirates." - sea_witch: "&5 A witch adrift, and she has seen you." + guardian_picket: "Guardians rise in your path." + drowned_raiders: "Drowned surface off the bow - some carry tridents." + deep_terror: "Something vast and ancient rises from the deep. Run." + phantom_flight: "Wings in the dark above the mast." + pirate_crew: "A crewed boat turns toward you. Pirates." + sea_witch: "A witch adrift, and she has seen you." warp: - not-in-boat: "&c You need to be aboard a boat to warp." - not-at-island: "&c The warp only engages within an island's waters." - no-destinations: "&c No charted destinations - row into new waters first." - not-enough-fuel: "&c Not enough fuel aboard for that jump." - arrived: "&b The warp releases you at [name]." - hold-course: "&b Hold your course for [seconds] seconds - the warp is spinning up..." - course-broken: "&c You broke course - the warp fizzles out. Fuel salvaged." + not-in-boat: "You need to be aboard a boat to warp." + not-at-island: "The warp only engages within an island's waters." + no-destinations: "No charted destinations - row into new waters first." + not-enough-fuel: "Not enough fuel aboard for that jump." + arrived: "The warp releases you at [name]." + hold-course: "Hold your course for [seconds] seconds - the warp is spinning up..." + course-broken: "You broke course - the warp fizzles out. Fuel salvaged." From 90547f8bb6ae85d38d34dfc4c0b858f3d8243405 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 20:10:51 -0700 Subject: [PATCH 047/112] Move all player-facing text into the locale, via the User API Never convert MiniMessage by hand: BentoBox's User API is locale-aware. Auditing for that turned up a bigger problem - dialog titles, buttons and tooltips, boss bar text, hologram labels, item names and lore were hardcoded English in code and could never have been translated. Everything now resolves through the locale with user.getTranslationAsComponent(...), and the charted action bar uses the locale's [actionbar] marker so BentoBox delivers and formats it. No Component.text literals or MiniMessage references remain in src/main. 154 tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 17 +- docs/PROGRESS.md | 21 ++- .../world/bentobox/tradewinds/TradeWinds.java | 1 + .../tradewinds/economy/MarketService.java | 21 ++- .../tradewinds/economy/TradeDialog.java | 149 ++++++++++-------- .../tradewinds/tasks/NavigationBarTask.java | 10 +- .../tradewinds/travel/ChartHolograms.java | 19 ++- .../tradewinds/travel/ChartingListener.java | 9 +- .../tradewinds/travel/ExpanderListener.java | 6 +- .../tradewinds/travel/IntersticeService.java | 19 ++- .../tradewinds/travel/StarChartService.java | 6 +- .../tradewinds/travel/StarterKit.java | 19 +-- .../tradewinds/travel/WarpService.java | 26 +-- src/main/resources/locales/en-US.yml | 68 +++++++- 14 files changed, 255 insertions(+), 136 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 860945f..f9356b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,9 +75,14 @@ Follow BentoBox conventions: `Config` + `@ConfigEntry`/`@StoreAt`, /`DefaultAdminCommand`, locale keys under `tradewinds.`, every gameplay number in config from the stage it's introduced — no hardcoded gameplay values. -**Locale formatting is MiniMessage** (`text`, ``) — -the BentoBox standard; legacy `&` codes are deprecated, never add new ones. -When sending a translated string somewhere that takes a Component (action -bars, titles, holograms), deserialize it with -`MiniMessage.miniMessage().deserialize(...)` rather than `Component.text(...)`, -or the tags print literally. +**All player-facing text lives in the locale** — never build strings or +Components in code, or the game mode cannot be translated. Locale formatting +is MiniMessage (`text`); legacy `&` codes are deprecated. +Never call MiniMessage yourself: use the `User` API, which is locale-aware — +`user.sendMessage(key, vars...)` for chat, and +`user.getTranslationAsComponent(key, vars...)` wherever a Component is needed +(dialogs, boss bars, holograms, item names, inventory titles). The no-variable +call is ambiguous between overloads, so pass `new String[0]`. +Locale strings can also carry `[actionbar]`, `[title]`, `[subtitle]` and +`[sound:...]` markers — BentoBox routes and formats those itself, so prefer +them over calling `sendActionBar`/`playSound` around a message. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7e5e7bd..5487413 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,7 +3,26 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. -## Locale converted to MiniMessage (2026-07-31) +## All player-facing text moved into the locale (2026-07-31) + +Ben's correction: never do MiniMessage conversion by hand - BentoBox's User +API is locale-aware and does it (`getTranslationAsComponent`, +`sendMessage`). Auditing for that exposed a bigger problem of my own making: +dialog titles, buttons and tooltips, boss bar text, hologram labels, item +names and lore were all HARDCODED ENGLISH in code, so none of it could ever +be translated. +Everything now goes through the locale: ~50 new `tradewinds.ui.*`, +`tradewinds.hud.*`, `tradewinds.hologram.*` and `tradewinds.item.*` keys, +fetched with `user.getTranslationAsComponent(...)`. The charted action bar +uses the locale's `[actionbar]` marker so BentoBox delivers it, rather than +calling sendActionBar around a manually parsed string. +Audit is clean: no `Component.text("...")` literals and no MiniMessage +references remain in src/main. Gotcha: the no-variable +`getTranslationAsComponent(key)` call is ambiguous between the String... and +TagResolver... overloads - pass `new String[0]`. +Convention recorded in CLAUDE.md, including the [actionbar]/[title]/[sound] +markers, which also offer a tidier route for the trade sounds later. +154 tests green. Ben: MiniMessage is the standard from now on. en-US.yml converted from legacy ampersand codes to closed MiniMessage tags, matching core's own locale style diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index d6d43bd..0973cc2 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -22,6 +22,7 @@ import world.bentobox.bentobox.api.commands.island.IslandSettingsCommand; import world.bentobox.bentobox.api.configuration.Config; import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.lists.Flags; import world.bentobox.tradewinds.commands.AdminIslandsCommand; import world.bentobox.tradewinds.commands.AdminReflagCommand; diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 33cf6e8..469bbc6 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -138,7 +138,7 @@ public ItemStack stamp(ItemStack item) { meta.getPersistentDataContainer().set(STAMP_KEY, PersistentDataType.STRING, "stamped"); java.util.List lore = meta.lore() == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(meta.lore()); - lore.add(Component.text("\u2693 Customs Stamped", NamedTextColor.DARK_AQUA)); + lore.add(text("tradewinds.item.stamp")); meta.lore(lore); if (addon.getSettings().isStampGlint()) { meta.setEnchantmentGlintOverride(true); @@ -328,15 +328,23 @@ public ItemStack expanderItem() { ItemStack item = new ItemStack(Material.WHITE_SHULKER_BOX); ItemMeta meta = item.getItemMeta(); if (meta != null) { - meta.displayName(Component.text("Cargo Expander", NamedTextColor.GOLD)); - meta.lore(java.util.List.of(Component.text("Expands your boat's hold.", NamedTextColor.GRAY), - Component.text("Stow in a chest boat.", NamedTextColor.GRAY))); + meta.displayName(text("tradewinds.item.expander")); + meta.lore(java.util.List.of(text("tradewinds.item.expander-lore"))); meta.getPersistentDataContainer().set(EXPANDER_KEY, PersistentDataType.STRING, "expander"); item.setItemMeta(meta); } return item; } + /** + * A translated component from the console locale - for item names and lore, + * which belong to the item rather than to any one viewer. + */ + private Component text(String key) { + return User.getInstance(org.bukkit.Bukkit.getConsoleSender()).getTranslationAsComponent(key, + new String[0]); + } + /** * A trading pouch: the first rung of cargo space, and the only hold a new * sailor has. @@ -345,9 +353,8 @@ public ItemStack pouchItem() { ItemStack pouch = new ItemStack(Material.BUNDLE); ItemMeta meta = pouch.getItemMeta(); if (meta != null) { - meta.displayName(Component.text("Trading Pouch", NamedTextColor.GOLD)); - meta.lore(java.util.List.of(Component.text("Cargo space. Stow goods to sell them.", - NamedTextColor.GRAY))); + meta.displayName(text("tradewinds.item.pouch")); + meta.lore(java.util.List.of(text("tradewinds.item.pouch-lore"))); pouch.setItemMeta(meta); } return pouch; diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 1fa8fde..223dbb4 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -31,6 +31,9 @@ */ public class TradeDialog { + /** Disambiguates the no-variable getTranslationAsComponent overloads. */ + private static final String[] NO_VARS = new String[0]; + /** Sell/buy quantity steps shown side by side per cargo row. */ private static final int MID_BATCH = 16; private static final int BIG_BATCH = 64; @@ -50,20 +53,20 @@ public void openMain(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); // No sell button when the hold has nothing this island pays for if (!sellOffers(player, spec).isEmpty()) { - buttons.add(button("Sell cargo", NamedTextColor.YELLOW, "Sell your hold's goods here", - () -> openSell(player, spec))); + buttons.add(button(player, "market.sell", "market.sell-tooltip", () -> openSell(player, spec))); } if (!addon.getMarketService().saleCatalog(spec).isEmpty()) { - buttons.add(button("Buy goods", NamedTextColor.AQUA, "Buy this island's trade goods into your hold", - () -> openBuy(player, spec, addon.getMarketService().saleCatalog(spec), " - Buying"))); + buttons.add(button(player, "market.buy", "market.buy-tooltip", + () -> openBuy(player, spec, addon.getMarketService().saleCatalog(spec), "buying"))); } - buttons.add(button("Outfitter", NamedTextColor.GREEN, "Food, fuel and gear - straight to your pack", + buttons.add(button(player, "market.outfitter", "market.outfitter-tooltip", () -> openOutfitter(player, spec))); - buttons.add(button("Shipwright", NamedTextColor.GOLD, "Boats, chest boats and cargo expanders", + buttons.add(button(player, "market.shipwright", "market.shipwright-tooltip", () -> openShipwright(player, spec))); - show(player, spec.name() + " Market", - List.of(spec.type().name() + ", " + spec.band().getDisplayName(), statusLine(player)), buttons, - closeButton(), 1); + show(player, ui(player, "market.title", "[name]", spec.name()), + List.of(ui(player, "market.subtitle", "[type]", spec.type().name(), "[band]", + spec.band().getDisplayName()), statusLine(player)), + buttons, closeButton(player), 1); } /** @@ -79,20 +82,24 @@ public void openSell(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); for (SellOffer offer : offers.subList(0, Math.min(MAX_ROWS, offers.size()))) { String name = MarketService.pretty(offer.material()); - String each = String.format("$%.2f each", offer.unitPrice()); - buttons.add(button(name + " x1", NamedTextColor.YELLOW, each, + Component each = ui(player, "market.sell-tooltip-each", "[price]", + String.format("%.2f", offer.unitPrice())); + buttons.add(button(ui(player, "market.sell-one", "[material]", name), each, () -> sellThenReopen(player, spec, offer.material(), 1))); - buttons.add(button(name + " x" + MID_BATCH, NamedTextColor.YELLOW, - String.format("%s - up to $%.2f", each, offer.unitPrice() * MID_BATCH), + buttons.add(button(ui(player, "market.sell-batch", "[material]", name, "[amount]", + String.valueOf(MID_BATCH)), each, () -> sellThenReopen(player, spec, offer.material(), MID_BATCH))); - buttons.add(button(String.format("All %d - $%.2f", offer.amount(), offer.total()), NamedTextColor.GOLD, - each, () -> sellThenReopen(player, spec, offer.material(), Integer.MAX_VALUE))); + buttons.add(button(ui(player, "market.sell-all", "[amount]", String.valueOf(offer.amount()), + "[price]", String.format("%.2f", offer.total())), each, + () -> sellThenReopen(player, spec, offer.material(), Integer.MAX_VALUE))); } - List body = new ArrayList<>(List.of("The island pays for goods in your hold", statusLine(player))); + List body = new ArrayList<>(List.of(ui(player, "market.selling-body", NO_VARS), + statusLine(player))); if (offers.size() > MAX_ROWS) { - body.add("(showing the first " + MAX_ROWS + " cargo types - sell some to see the rest)"); + body.add(ui(player, "market.selling-truncated", "[number]", String.valueOf(MAX_ROWS))); } - show(player, spec.name() + " - Selling", body, buttons, backButton(player, spec), 3); + show(player, ui(player, "market.selling-title", "[name]", spec.name()), body, buttons, + backButton(player, spec), 3); } private void sellThenReopen(Player player, IslandSpec spec, Material material, int amount) { @@ -103,19 +110,22 @@ private void sellThenReopen(Player player, IslandSpec spec, Material material, i /** * The buy page: the island's catalog in batches. */ - public void openBuy(Player player, IslandSpec spec, List catalog, String titleSuffix) { + public void openBuy(Player player, IslandSpec spec, List catalog, String page) { List buttons = new ArrayList<>(); for (Material material : catalog) { Optional price = addon.getMarketService().playerBuysAt(spec, material); price.ifPresent(unit -> { String name = MarketService.pretty(material); - String each = String.format("$%.2f each; limited by balance and hold space", unit); - buttons.add(button(String.format("%s x1 - $%.2f", name, unit), NamedTextColor.AQUA, each, - () -> buyThenReopen(player, spec, material, 1, catalog, titleSuffix))); - buttons.add(button(String.format("x%d - $%.2f", MID_BATCH, unit * MID_BATCH), NamedTextColor.AQUA, - each, () -> buyThenReopen(player, spec, material, MID_BATCH, catalog, titleSuffix))); - buttons.add(button(String.format("x%d - $%.2f", BIG_BATCH, unit * BIG_BATCH), NamedTextColor.AQUA, - each, () -> buyThenReopen(player, spec, material, BIG_BATCH, catalog, titleSuffix))); + Component each = ui(player, "market.buy-tooltip-each", "[price]", String.format("%.2f", unit)); + buttons.add(button(ui(player, "market.buy-one", "[material]", name, "[price]", + String.format("%.2f", unit)), each, + () -> buyThenReopen(player, spec, material, 1, catalog, page))); + buttons.add(button(ui(player, "market.buy-batch", "[amount]", String.valueOf(MID_BATCH), + "[price]", String.format("%.2f", unit * MID_BATCH)), each, + () -> buyThenReopen(player, spec, material, MID_BATCH, catalog, page))); + buttons.add(button(ui(player, "market.buy-batch", "[amount]", String.valueOf(BIG_BATCH), + "[price]", String.format("%.2f", unit * BIG_BATCH)), each, + () -> buyThenReopen(player, spec, material, BIG_BATCH, catalog, page))); }); } if (buttons.isEmpty()) { @@ -123,15 +133,15 @@ public void openBuy(Player player, IslandSpec spec, List catalog, Stri openMain(player, spec); return; } - show(player, spec.name() + titleSuffix, - List.of("Goods sold into your hold - customs stamped", statusLine(player)), buttons, + show(player, ui(player, "market." + page + "-title", "[name]", spec.name()), + List.of(ui(player, "market." + page + "-body", NO_VARS), statusLine(player)), buttons, backButton(player, spec), 3); } private void buyThenReopen(Player player, IslandSpec spec, Material material, int amount, List catalog, - String titleSuffix) { + String page) { addon.getMarketService().buy(player, spec, material, amount); - openBuy(player, spec, catalog, titleSuffix); + openBuy(player, spec, catalog, page); } /** @@ -144,12 +154,13 @@ public void openOutfitter(Player player, IslandSpec spec) { for (Material material : addon.getMarketService().outfitterCatalog(spec)) { addon.getMarketService().playerBuysAt(spec, material).ifPresent(unit -> { String name = MarketService.pretty(material); - String each = String.format("$%.2f each - carried in your pack, not the hold", unit); - buttons.add(button(String.format("%s x1 - $%.2f", name, unit), NamedTextColor.GREEN, each, + Component each = ui(player, "market.outfit-tooltip", "[price]", String.format("%.2f", unit)); + buttons.add(button(ui(player, "market.outfit-one", "[material]", name, "[price]", + String.format("%.2f", unit)), each, () -> outfitThenReopen(player, spec, material, 1))); if (new org.bukkit.inventory.ItemStack(material).getMaxStackSize() > 1) { - buttons.add(button(String.format("x%d - $%.2f", MID_BATCH, unit * MID_BATCH), - NamedTextColor.GREEN, each, + buttons.add(button(ui(player, "market.outfit-batch", "[amount]", String.valueOf(MID_BATCH), + "[price]", String.format("%.2f", unit * MID_BATCH)), each, () -> outfitThenReopen(player, spec, material, MID_BATCH))); } }); @@ -159,8 +170,8 @@ public void openOutfitter(Player player, IslandSpec spec) { openMain(player, spec); return; } - show(player, spec.name() + " - Outfitter", - List.of("Stores for the sailing life - food, fuel and gear", statusLine(player)), buttons, + show(player, ui(player, "market.outfitter-title", "[name]", spec.name()), + List.of(ui(player, "market.outfitter-body", NO_VARS), statusLine(player)), buttons, backButton(player, spec), 2); } @@ -177,8 +188,9 @@ public void openShipwright(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); for (Material hull : addon.getMarketService().shipwrightCatalog()) { addon.getMarketService().playerBuysAt(spec, hull).ifPresent(unit -> buttons.add(button( - String.format("%s - $%.2f", MarketService.pretty(hull), unit), NamedTextColor.GOLD, - "Delivered to your inventory - place it at the dock", + ui(player, "market.hull", "[material]", MarketService.pretty(hull), "[price]", + String.format("%.2f", unit)), + ui(player, "market.hull-tooltip", NO_VARS), () -> { addon.getMarketService().buyToInventory(player, spec, hull, 1); openShipwright(player, spec); @@ -187,10 +199,10 @@ public void openShipwright(Player player, IslandSpec spec) { int pouches = addon.getHoldService().pouchCount(player); int maxPouches = addon.getSettings().getMaxBundles(); if (pouches < maxPouches) { - buttons.add(button(String.format("Trading Pouch - $%.0f", - addon.getMarketService().pouchPrice(player)), NamedTextColor.GOLD, - "Holds 64 items (" + pouches + "/" + maxPouches + " carried). Cargo expanders carry " - + "27 stacks each - the way to a real hold.", + buttons.add(button(ui(player, "market.pouch", "[price]", + String.format("%.0f", addon.getMarketService().pouchPrice(player))), + ui(player, "market.pouch-tooltip", "[owned]", String.valueOf(pouches), "[cap]", + String.valueOf(maxPouches)), () -> { addon.getMarketService().buyPouch(player); openShipwright(player, spec); @@ -199,27 +211,24 @@ public void openShipwright(Player player, IslandSpec spec) { int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); if (owned < addon.getSettings().getExpanderCap()) { double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); - buttons.add(button(String.format("Cargo Expander - $%.0f", price), NamedTextColor.GOLD, - "27 stacks of cargo space (" + owned + "/" + addon.getSettings().getExpanderCap() - + " owned). Price doubles each time. Carried in your pack - open it anywhere.", + buttons.add(button(ui(player, "market.expander", "[price]", String.format("%.0f", price)), + ui(player, "market.expander-tooltip", "[owned]", String.valueOf(owned), "[cap]", + String.valueOf(addon.getSettings().getExpanderCap())), () -> { addon.getMarketService().buyExpander(player); openShipwright(player, spec); })); } if (addon.getMarketService().isDestitute(player)) { - buttons.add(button("Harbourmaster's charity", NamedTextColor.GREEN, - "Shipwrecked and penniless? Take a pouch, and a boat if you need one. " - + "Nobody starves in this port.", + buttons.add(button(player, "market.charity", "market.charity-tooltip", () -> { addon.getMarketService().claimCharity(player); openShipwright(player, spec); })); } - show(player, spec.name() + " - Shipwright", - List.of("Hulls and holds. Smart sailors craft their own boats from wild timber.", - statusLine(player)), - buttons, backButton(player, spec), 1); + show(player, ui(player, "market.shipwright-title", "[name]", spec.name()), + List.of(ui(player, "market.shipwright-body", NO_VARS), statusLine(player)), buttons, + backButton(player, spec), 1); } /** @@ -245,34 +254,44 @@ List sellOffers(Player player, IslandSpec spec) { * Balance and hold space - shown on every market screen so traders always * know what they can afford and what they can carry. */ - private String statusLine(Player player) { + /** + * A translated UI component. All dialog text goes through the locale so it + * can be translated - never build player-facing strings in code. + */ + private Component ui(Player player, String key, String... variables) { + return User.getInstance(player).getTranslationAsComponent("tradewinds.ui." + key, variables); + } + + private Component statusLine(Player player) { double balance = addon.getPlugin().getVault().map(v -> v.getBalance(User.getInstance(player))).orElse(0.0); - return String.format("Balance: $%.2f | Hold space: ~%d items", balance, - addon.getHoldService().freeSpace(player)); + return ui(player, "market.status", "[balance]", String.format("%.2f", balance), "[space]", + String.valueOf(addon.getHoldService().freeSpace(player))); } private ActionButton backButton(Player player, IslandSpec spec) { - return button("< Back", NamedTextColor.GRAY, "Back to the market menu", () -> openMain(player, spec)); + return button(player, "market.back", "market.back-tooltip", () -> openMain(player, spec)); } - private ActionButton closeButton() { + private ActionButton closeButton(Player player) { // No action: clicking simply closes the dialog - return ActionButton.builder(Component.text("Close", NamedTextColor.GRAY)).width(300).build(); + return ActionButton.builder(ui(player, "market.close", NO_VARS)).width(300).build(); + } + + private ActionButton button(Player player, String labelKey, String tooltipKey, Runnable action) { + return button(ui(player, labelKey, NO_VARS), ui(player, tooltipKey, NO_VARS), action); } - private ActionButton button(String label, NamedTextColor color, String tooltip, Runnable action) { - return ActionButton.create(Component.text(label, color), Component.text(tooltip), 140, + private ActionButton button(Component label, Component tooltip, Runnable action) { + return ActionButton.create(label, tooltip, 140, DialogAction.customClick((response, audience) -> action.run(), ClickCallback.Options.builder().build())); } - private void show(Player player, String title, List bodyLines, List buttons, + private void show(Player player, Component title, List bodyLines, List buttons, ActionButton exitButton, int columns) { - List body = bodyLines.stream() - .map(line -> (DialogBody) DialogBody.plainMessage(Component.text(line, NamedTextColor.GRAY))) - .toList(); + List body = bodyLines.stream().map(line -> (DialogBody) DialogBody.plainMessage(line)).toList(); Dialog dialog = Dialog.create(factory -> factory.empty() - .base(DialogBase.builder(Component.text(title)).body(body).build()) + .base(DialogBase.builder(title).body(body).build()) .type(DialogType.multiAction(buttons).exitAction(exitButton).columns(columns).build())); player.showDialog(dialog); } diff --git a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java index 1bdb5b9..c900afe 100644 --- a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java +++ b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java @@ -11,7 +11,7 @@ import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.galaxy.DockPlan; import world.bentobox.tradewinds.galaxy.GalaxyEngine; @@ -104,10 +104,10 @@ record Reading(IslandSpec island, int dockDistance, float progress) { } private void show(Player player, Reading reading) { - Component name = Component.text(reading.island().name(), NamedTextColor.AQUA) - .append(Component.text(" | " + addon.getPlayerStanding(player.getUniqueId()), - NamedTextColor.WHITE)) - .append(Component.text(" | Dock " + reading.dockDistance() + "m", NamedTextColor.YELLOW)); + Component name = User.getInstance(player).getTranslationAsComponent("tradewinds.hud.navigation", + "[name]", reading.island().name(), + "[standing]", addon.getPlayerStanding(player.getUniqueId()), + "[distance]", String.valueOf(reading.dockDistance())); BossBar bar = bars.computeIfAbsent(player.getUniqueId(), id -> BossBar.bossBar(name, reading.progress(), color(reading.island().band()), BossBar.Overlay.PROGRESS)); diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index c7f03e5..7c32d4f 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -16,7 +16,7 @@ import org.bukkit.entity.TextDisplay; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.galaxy.IslandSpec; @@ -92,7 +92,7 @@ public void show(Player player) { // Boarding or charting at a port copies its harbour charts List scanned = addon.getPlayerDataManager().portScan(player); if (!scanned.isEmpty()) { - world.bentobox.bentobox.api.user.User.getInstance(player).sendMessage("tradewinds.chart.port-scan", + User.getInstance(player).sendMessage("tradewinds.chart.port-scan", world.bentobox.bentobox.api.localization.TextVariables.NUMBER, String.valueOf(scanned.size())); } @@ -106,7 +106,7 @@ public void show(Player player) { List spawned = new ArrayList<>(); for (Marker marker : markers) { TextDisplay display = eye.getWorld().spawn(eye.clone().add(0, 1.0, 0), TextDisplay.class); - display.text(label(marker)); + display.text(label(player, marker)); display.setBillboard(Billboard.CENTER); display.setSeeThrough(true); display.setBackgroundColor(Color.fromARGB(120, 0, 20, 40)); @@ -133,14 +133,13 @@ public void show(Player player) { addon.getSettings().getChartHologramSeconds() * 20L); } - private Component label(Marker marker) { + private Component label(Player player, Marker marker) { IslandSpec spec = marker.island(); - return Component.text(spec.name(), NamedTextColor.AQUA) - .append(Component.newline()) - .append(Component.text(spec.type().name() + ", " + spec.band().getDisplayName(), - NamedTextColor.GRAY)) - .append(Component.newline()) - .append(Component.text(marker.distance() + "m", NamedTextColor.YELLOW)); + return User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.island", + "[name]", spec.name(), + "[type]", spec.type().name(), + "[band]", spec.band().getDisplayName(), + "[distance]", String.valueOf(marker.distance())); } private List chartedIslands(Player player) { diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java index bf9f7ae..1cc3aaa 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartingListener.java @@ -10,7 +10,6 @@ import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.vehicle.VehicleEnterEvent; -import net.kyori.adventure.text.minimessage.MiniMessage; import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.api.events.IslandChartedEvent; @@ -79,11 +78,9 @@ public void onMove(PlayerMoveEvent event) { .filter(spec -> spec.distanceSquared(x, z) <= (long) range * range) .filter(data::chart) .forEach(spec -> { - User user = User.getInstance(player); - // Locale strings are MiniMessage: deserialize rather than - // wrapping raw text, or the tags would print literally - player.sendActionBar(MiniMessage.miniMessage().deserialize( - user.getTranslation("tradewinds.chart.charted", "[name]", spec.name()))); + // The locale string carries an [actionbar] marker, so + // BentoBox delivers and formats it - never do that here + User.getInstance(player).sendMessage("tradewinds.chart.charted", "[name]", spec.name()); addon.getPlayerDataManager().save(player.getUniqueId()); Bukkit.getPluginManager().callEvent(new IslandChartedEvent(player.getUniqueId(), spec)); }); diff --git a/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java b/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java index 68f5068..a903f16 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java @@ -20,8 +20,6 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BlockStateMeta; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import world.bentobox.tradewinds.TradeWinds; /** @@ -60,8 +58,8 @@ public void onRightClick(PlayerInteractEvent event) { || !(meta.getBlockState() instanceof ShulkerBox box)) { return; } - Inventory view = Bukkit.createInventory(null, 27, - Component.text("Cargo Expander", NamedTextColor.DARK_AQUA)); + Inventory view = Bukkit.createInventory(null, 27, world.bentobox.bentobox.api.user.User + .getInstance(player).getTranslationAsComponent("tradewinds.item.expander-view", new String[0])); view.setContents(box.getInventory().getContents()); open.put(player.getUniqueId(), player.getInventory().getHeldItemSlot()); player.openInventory(view); diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index 41ba6d4..f2b852c 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -43,6 +43,12 @@ */ public class IntersticeService { + /** + * Disambiguates User#getTranslationAsComponent, whose no-variable call is + * ambiguous between the String... and TagResolver... overloads. + */ + private static final String[] NO_VARS = new String[0]; + /** Player -> the destination cell they are still owed, free of charge. */ private final Map pendingDestination = new ConcurrentHashMap<>(); private final Map lastPrompt = new ConcurrentHashMap<>(); @@ -150,16 +156,17 @@ public void openReEngageDialog(Player player) { if (target == null) { return; } + User user = User.getInstance(player); ActionButton engage = ActionButton.create( - Component.text("Re-engage the warp to " + target.name(), NamedTextColor.AQUA), - Component.text("Free - the fuel was spent when you first engaged"), 320, + user.getTranslationAsComponent("tradewinds.ui.interstice.re-engage", "[name]", target.name()), + user.getTranslationAsComponent("tradewinds.ui.interstice.re-engage-tooltip", NO_VARS), 320, DialogAction.customClick((response, audience) -> reEngage(player, target), ClickCallback.Options.builder().build())); Dialog dialog = Dialog.create(factory -> factory.empty() - .base(DialogBase.builder(Component.text("The Interstice")) - .body(java.util.List.of(DialogBody.plainMessage(Component.text( - "Dark water, and something screaming above it. Your course is still paid for.", - NamedTextColor.GRAY)))) + .base(DialogBase.builder( + user.getTranslationAsComponent("tradewinds.ui.interstice.title", NO_VARS)) + .body(java.util.List.of(DialogBody.plainMessage( + user.getTranslationAsComponent("tradewinds.ui.interstice.body", NO_VARS)))) .build()) .type(DialogType.multiAction(java.util.List.of(engage)).columns(1).build())); player.showDialog(dialog); diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java b/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java index df4651d..ad143ee 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java @@ -10,8 +10,7 @@ import org.bukkit.inventory.meta.MapMeta; import org.bukkit.map.MapView; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; +import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.Database; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.dataobjects.TWWorldData; @@ -47,7 +46,8 @@ public void give(Player player) { ItemStack item = new ItemStack(Material.FILLED_MAP); if (item.getItemMeta() instanceof MapMeta meta) { meta.setMapView(chartView()); - meta.displayName(Component.text("Star Chart", NamedTextColor.AQUA)); + meta.displayName(User.getInstance(player).getTranslationAsComponent("tradewinds.item.starchart", + new String[0])); item.setItemMeta(meta); } player.getInventory().addItem(item).values() diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java index 0715be6..3aabefc 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java @@ -71,18 +71,15 @@ public void onSpawnArrival(Player player) { } private ItemStack tradingBundle() { - ItemStack bundle = addon.getMarketService().pouchItem(); - ItemMeta meta = bundle.getItemMeta(); - if (meta != null) { - // A little coal in the bundle: it is hold cargo, so the first - // island hop can be a warp instead of a long row - int coal = addon.getSettings().getStarterCoal(); - if (coal > 0 && meta instanceof org.bukkit.inventory.meta.BundleMeta bundleMeta) { - bundleMeta.setItems(java.util.List.of(new ItemStack(Material.COAL, coal))); - } - bundle.setItemMeta(meta); + ItemStack pouch = addon.getMarketService().pouchItem(); + // A little coal in the pouch: it is hold cargo, so the first island hop + // can be a warp instead of a long row + int coal = addon.getSettings().getStarterCoal(); + if (coal > 0 && pouch.getItemMeta() instanceof org.bukkit.inventory.meta.BundleMeta bundleMeta) { + bundleMeta.setItems(java.util.List.of(new ItemStack(Material.COAL, coal))); + pouch.setItemMeta(bundleMeta); } - return bundle; + return pouch; } private void launchBoat(Player player, Location spawn, Material boatMaterial) { diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index 96d7647..1915b60 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -86,14 +86,16 @@ public void openDialog(Player player, IslandSpec origin) { double fuelAboard = addon.getFuelService().holdFuel(player); List destinations = destinations(player, origin, fuelAboard); if (destinations.isEmpty()) { - player.sendMessage(user(player).getTranslation("tradewinds.warp.no-destinations")); + user(player).sendMessage("tradewinds.warp.no-destinations"); return; } List buttons = destinations.stream().map(dest -> button(player, origin, dest)).toList(); Dialog dialog = Dialog.create(factory -> factory.empty() - .base(DialogBase.builder(Component.text("Warp - " + origin.name())) - .body(List.of(DialogBody.plainMessage(Component - .text("Fuel aboard: " + (int) fuelAboard + " units", NamedTextColor.AQUA)))) + .base(DialogBase.builder(user(player).getTranslationAsComponent("tradewinds.ui.warp.title", + "[name]", origin.name())) + .body(List.of(DialogBody.plainMessage( + user(player).getTranslationAsComponent("tradewinds.ui.warp.fuel", "[amount]", + String.valueOf((int) fuelAboard))))) .build()) .type(DialogType.multiAction(buttons).columns(1).build())); player.showDialog(dialog); @@ -101,16 +103,18 @@ public void openDialog(Player player, IslandSpec origin) { private ActionButton button(Player player, IslandSpec origin, Destination dest) { IslandSpec spec = dest.island(); - Component label = Component.text(spec.name() + " - " + dest.fuelCost() + " fuel", - dest.affordable() ? NamedTextColor.WHITE : NamedTextColor.DARK_GRAY); - Component tooltip = Component.text(spec.type().name() + ", " + spec.band().getDisplayName() + ", " - + (int) Math.sqrt(spec.distanceSquared(origin.centerX(), origin.centerZ())) + " blocks"); + Component label = user(player).getTranslationAsComponent( + dest.affordable() ? "tradewinds.ui.warp.destination" : "tradewinds.ui.warp.destination-poor", + "[name]", spec.name(), "[fuel]", String.valueOf(dest.fuelCost())); + Component tooltip = user(player).getTranslationAsComponent("tradewinds.ui.warp.destination-tooltip", + "[type]", spec.type().name(), "[band]", spec.band().getDisplayName(), "[distance]", + String.valueOf((int) Math.sqrt(spec.distanceSquared(origin.centerX(), origin.centerZ())))); DialogAction action = DialogAction.customClick( (response, audience) -> { if (dest.affordable()) { warp(player, origin, spec, dest.fuelCost()); } else { - player.sendMessage(user(player).getTranslation("tradewinds.warp.not-enough-fuel")); + user(player).sendMessage("tradewinds.warp.not-enough-fuel"); } }, ClickCallback.Options.builder().build()); return ActionButton.create(label, tooltip, 250, action); @@ -128,7 +132,7 @@ public void warp(Player player, IslandSpec from, IslandSpec to, int fuelCost) { return; } if (!addon.getFuelService().consume(player, fuelCost)) { - player.sendMessage(user(player).getTranslation("tradewinds.warp.not-enough-fuel")); + user(player).sendMessage("tradewinds.warp.not-enough-fuel"); return; } standStillThen(player, () -> jump(player, from, to, fuelCost), fuelCost); @@ -224,7 +228,7 @@ private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int f () -> boat.addPassenger(player))); } arrivalEffects(player, target); - player.sendMessage(user(player).getTranslation("tradewinds.warp.arrived", "[name]", to.name())); + user(player).sendMessage("tradewinds.warp.arrived", "[name]", to.name()); Bukkit.getPluginManager().callEvent(new TWWarpCompletedEvent(player, bearingFrom, to, fuelCost)); }); } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 10cfe7e..ef20ae0 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -49,8 +49,74 @@ tradewinds: given: "Your trading career begins: a boat, a trading pouch, and a little coal. Buy low, sell high." status: clean: "Clean" + hud: + navigation: "[name] | [standing] | Dock [distance]m" + hologram: + island: "[name][type], [band][distance]m" + ui: + market: + title: "[name] Market" + subtitle: "[type], [band]" + status: "Balance: $[balance] | Hold space: ~[space] items" + sell: "Sell cargo" + sell-tooltip: "Sell your hold's goods here" + buy: "Buy goods" + buy-tooltip: "Buy this island's trade goods into your hold" + outfitter: "Outfitter" + outfitter-tooltip: "Food, fuel and gear - straight to your pack" + shipwright: "Shipwright" + shipwright-tooltip: "Boats, chest boats and cargo expanders" + back: "< Back" + back-tooltip: "Back to the market menu" + close: "Close" + selling-title: "[name] - Selling" + selling-body: "The island pays for goods in your hold" + selling-truncated: "(showing the first [number] cargo types - sell some to see the rest)" + buying-title: "[name] - Buying" + buying-body: "Goods sold into your hold - customs stamped" + outfitter-title: "[name] - Outfitter" + outfitter-body: "Stores for the sailing life - food, fuel and gear" + shipwright-title: "[name] - Shipwright" + shipwright-body: "Hulls and holds. Smart sailors craft their own boats from wild timber." + sell-one: "[material] x1" + sell-batch: "[material] x[amount]" + sell-all: "All [amount] - $[price]" + sell-tooltip-each: "$[price] each" + buy-one: "[material] x1 - $[price]" + buy-batch: "x[amount] - $[price]" + buy-tooltip-each: "$[price] each; limited by balance and hold space" + outfit-one: "[material] x1 - $[price]" + outfit-batch: "x[amount] - $[price]" + outfit-tooltip: "$[price] each - carried in your pack, not the hold" + hull: "[material] - $[price]" + hull-tooltip: "Delivered to your inventory - place it at the dock" + pouch: "Trading Pouch - $[price]" + pouch-tooltip: "Holds 64 items ([owned]/[cap] carried). Cargo expanders carry 27 stacks each." + expander: "Cargo Expander - $[price]" + expander-tooltip: "27 stacks of cargo space ([owned]/[cap] owned). Price doubles each time. Carried in your pack." + charity: "Harbourmaster's charity" + charity-tooltip: "Shipwrecked and penniless? Take a pouch, and a boat if you need one." + warp: + title: "Warp - [name]" + fuel: "Fuel aboard: [amount] units" + destination: "[name] - [fuel] fuel" + destination-poor: "[name] - [fuel] fuel" + destination-tooltip: "[type], [band], [distance] blocks" + interstice: + title: "The Interstice" + body: "Dark water, and something screaming above it. Your course is still paid for." + re-engage: "Re-engage the warp to [name]" + re-engage-tooltip: "Free - the fuel was spent when you first engaged" + item: + pouch: "Trading Pouch" + pouch-lore: "Cargo space. Stow goods to sell them." + expander: "Cargo Expander" + expander-lore: "Expands your hold. Right-click to open." + stamp: "Customs Stamped" + starchart: "Star Chart" + expander-view: "Cargo Expander" chart: - charted: "Charted [name]!" + charted: "[actionbar]Charted [name]!" port-scan: "You copy the harbour charts: [number] islands added to yours." holograms-shown: "Your chart shimmers into the air - row toward a name." starchart-given: "The Star Chart is yours - hold it to see your world." From 8ba6c75f3014dfde94d858f039e224383741e85d Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 21:44:12 -0700 Subject: [PATCH 048/112] Rebuild the sea: seabed shape, vanilla structures, findable islets Playtest over 130 regions found no wild islet at all, and called the sea floor "barren and repetitive". Two different causes. Islets were generating, just too rarely to meet: at grid 1200 x chance 0.3 the mean row to one was over a kilometre and each was a fixed 70-block cone. Now grid 900 x chance 0.55, and each islet rolls its own radius and height, so the sea holds sandbars through to proper little islands. Measured on the live seed: mean 544 blocks to the nearest, worst case 1,376. The flat Perlin floor was also the wrong tool. The floor is not scenery - its depth is what picks the ocean biome, and the biome is what tells vanilla where monuments, ruins and coral belong. So the seabed moves into the pure galaxy package, where terrain and biome read the same field: basins from shelf to abyssal plain, relief over them, ridged-noise rifts cutting narrow canyons, and seamounts that never break the surface. Near land the floor eases to a standard island shelf, so an island over an abyss still stands in shallow water. With that in place vanilla can furnish the sea: caves and structures default on, giving shipwrecks, warm and cold ocean ruins, monuments in the deep basins, buried treasure on the new islet beaches, and trial chambers in the rock. Structures are suppressed per-chunk over trading islands so nothing drops through a hand-built plaza. 164 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 54 +++ TRADEWINDS_SPEC.md | 28 +- docs/PROGRESS.md | 70 ++++ .../world/bentobox/tradewinds/Settings.java | 101 +++++- .../world/bentobox/tradewinds/TradeWinds.java | 18 +- .../tradewinds/galaxy/GalaxyConfig.java | 32 +- .../tradewinds/galaxy/GalaxyEngine.java | 314 ++++++++++++++++-- .../bentobox/tradewinds/galaxy/Islet.java | 43 +++ .../bentobox/tradewinds/galaxy/Noise.java | 51 +++ .../bentobox/tradewinds/galaxy/Seabed.java | 200 +++++++++++ .../tradewinds/galaxy/SeabedConfig.java | 65 ++++ .../tradewinds/galaxy/SurfaceKind.java | 16 + .../generator/ChunkGeneratorWorld.java | 269 +++++++++++---- .../tradewinds/generator/IslandPalette.java | 27 ++ .../generator/TradeWindsBiomeProvider.java | 7 +- src/main/resources/config.yml | 62 +++- .../tradewinds/galaxy/GalaxyEngineTest.java | 209 ++++++++++-- .../generator/ChunkGeneratorWorldTest.java | 90 ++++- .../TradeWindsBiomeProviderTest.java | 3 + 19 files changed, 1492 insertions(+), 167 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/Islet.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/SeabedConfig.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/SurfaceKind.java diff --git a/TESTING.md b/TESTING.md index 8e63517..2848f5b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -518,3 +518,57 @@ database (or use `/twadmin delete` on it), then restart. (A test walks 80,000 blocks asserting the temperature never skips a step.) - [ ] The same seed gives the same seas; a different seed gives different ones. - [ ] `world.vary-ocean-biomes: false` returns the world to a single ocean biome. + +### Stage 5j — the sea itself: seabed, structures, islets +**Needs a fresh world** — terrain and biomes are baked at generation, so delete +`world/dimensions/minecraft/tradewinds_world` (and the nether one) before testing. +The test server's config has already been updated to the new defaults. + +**Islets — the "I couldn't find a single one" fix** +- [ ] Sail in any direction from anywhere: you meet an islet within a few hundred + blocks, not a few thousand. (Measured on the live seed: mean 544 blocks to the + nearest, worst case 1,376.) +- [ ] Islets vary — sandbars barely clearing the water through to islands 140 blocks + across, and small ones are lower as well as narrower. +- [ ] Every islet has a sandy shoreline ring with beach biome (snowy beach in cold + seas), and grass or its own biome inland. +- [ ] Roughly 1 islet in 16 is **mushroom fields**: mycelium, mooshrooms, red and + brown mushrooms, and no hostile mobs spawning on it. + +**The sea floor** +- [ ] Dive in open water in several places: the floor is genuinely different depths — + sunlit banks around 14 blocks down, dark basins over 45. +- [ ] Find a **rift**: a narrow canyon wandering across the floor, dropping sharply + well below the surrounding seabed. +- [ ] Find a **seamount**: an underwater peak rising off a deep plain — and confirm it + never breaks the surface. Nothing but islands and islets should be land. +- [ ] The floor is patchy, not static: banks of sand, beds of gravel, clay pans, and + bare stone/tuff in the deeps — not alternating sand/sandstone every block. +- [ ] Water colour changes with depth as well as temperature: deep basins read as + deep ocean biomes (`F3` shows `deep_ocean`, `deep_cold_ocean`, ...). + +**What vanilla now puts there** (`make-structures: true`) +- [ ] **Shipwrecks** on the sea floor, and beached ones on islet shores. +- [ ] **Ocean ruins** — warm (sandstone) in warm/lukewarm water, cold (stone) in + colder water. They should match the water they are in. +- [ ] **Ocean monuments** in the deep basins, with guardians and elder guardians. + (Monuments only generate in deep ocean biomes, so this is the check that the + depth-to-biome mapping is working at all.) +- [ ] **Buried treasure** on islet beaches — a treasure map from a shipwreck or ruin + should lead somewhere real. +- [ ] **Trial chambers** in the rock under the sea floor: dig or cave down and find one. +- [ ] **No structure of any kind on a trading island** — no monument, ruin or village + through a plaza, dock or market. Fly around several islands and confirm. + (`world.keep-structures-off-islands: true`.) + +**Caves** (`make-caves: true`) +- [ ] Caves, ravines and caverns exist under the sea floor and inside the islands. +- [ ] Judgement call to make in play: cave mouths opening into the ocean leave dry air + pockets rather than flooding. That is intended (somewhere to surface, something + to mine) — but if it looks wrong, `world.make-caves: false` turns it off. +- [ ] Caves do not break into a market plaza or dock from below. + +**Regression** +- [ ] Trading islands still sit properly in the water, docks still run unbroken from + plaza to pier end, and the spawn island is unchanged — an island over a deep + basin must look the same as one over a shelf. diff --git a/TRADEWINDS_SPEC.md b/TRADEWINDS_SPEC.md index b4b6bbb..93ea9b3 100644 --- a/TRADEWINDS_SPEC.md +++ b/TRADEWINDS_SPEC.md @@ -56,12 +56,14 @@ A sea-trading game mode: an endless procedurally generated ocean dotted with **N ### 2.3 Chunk generator -`ChunkGeneratorWorld` derived from Poseidon (`PerlinOctaveGenerator(worldSeed, 8)`, scale 1/30, NOISE_MAX-style amplitude): - -- Everywhere: ocean floor noise from sea floor, water up to sea height, air above. -- Near an island center (from `GalaxyEngine` — O(1) neighbor-cell lookup per chunk): terrain amplitude is multiplied by a **radial falloff mask** `m(d)` (1 at center → 0 at island's terrain radius), lifting land above sea level at the center and shelving into ocean. Pure ocean elsewhere. -- **Biome:** `BiomeProvider` returns the island's biome within its terrain radius (per column), ocean/air biomes elsewhere (Poseidon's y-split pattern for sea vs air). -- Vanilla decoration on (`shouldGenerateDecorations`), caves/structures config, mobs on. Interstice variant: NETHER environment, sea of water over basalt/blackstone-ish floor, nether roof (AcidIsland `makeNetherRoof` pattern). +`ChunkGeneratorWorld` owns the *shape* of the world; vanilla furnishes it. Vanilla noise and surface are off, so no vanilla continent can ever appear — but its carvers, decorators, mobs and structure placement all stay on. + +- **The sea floor** (`galaxy.Seabed`, pure and unit-tested — it is not scenery, its depth picks the ocean biome): a broad **basin** field taking the floor from sunlit shelf (~14 blocks down) to abyssal plain (~46), **relief** rolling dunes over it, ridged-noise **rifts** cutting narrow canyons up to 26 blocks deeper, and **seamounts** rising off the deeper plains only. Seamounts are capped below the surface: the galaxy's islands stay the world's only land (principle 6). Floor material follows depth in patches — sand banks, gravel beds, clay pans, bare stone and tuff in the deeps. +- **Island shelf:** near any island or islet the natural floor eases to a standard shelf depth, so an island that falls over an abyssal plain still stands in shallow water and clears the waves by the same amount. This also makes each island's shoreline radius exact rather than noise-dependent. +- Near an island center (from `GalaxyEngine` — O(1) neighbor-cell lookup per chunk): the shelf is lifted by a **radial falloff mask** `m(d)` (1 at center → 0 at the island's terrain radius). +- **Biome:** `BiomeProvider` returns the island's biome within its terrain radius (per column); islets return their own biome with a sandy **beach ring** at the waterline; open sea returns an ocean biome chosen by seeded temperature *and depth* — deep water gets the `deep_*` variants. That depth-to-biome mapping is what tells vanilla where **ocean monuments** belong; temperature is what separates warm from cold **ocean ruins**. +- **Vanilla structures** (`world.make-structures`) supply shipwrecks, ocean ruins, monuments, buried treasure and trial chambers. They are suppressed per-chunk over trading islands (`world.keep-structures-off-islands`) so nothing drops through a hand-built plaza or dock; wild islets are fair game. **Vanilla caves** (`world.make-caves`) carve under the sea floor and inside the islands. +- Interstice variant: NETHER environment, its own shallow drab sea floor over basalt/soul sand, no galaxy, no structures. - **Island registration:** on first generation of an island's center chunk (or first entry — listener on chunk load), register an **unowned BentoBox island** at the center with range/protection from config and flags per security band. Island name announced on entry (BentoBox island enter event → action bar/title). ## 3. Travel @@ -103,10 +105,16 @@ TradeWinds runs **two parallel economies** connected at only three points: Traders buy ONLY stamped goods - money enters the game exclusively through trade margins on goods that money already bought. Farming cannot mint money. - **The vanilla survival economy (stuff).** **Wild islets** (small unnamed - islands seeded into empty galaxy cells; `galaxy.wild-islet-*`) are free - country: mine, farm, build, sleep. All protection flags default to allowed - outside named islands' protection ranges. Homemade goods are freely usable - (eat, wear, build, burn as warp fuel) but unsellable. + islands on their own fine grid; `galaxy.wild-islet-*`) are free country: + mine, farm, build, sleep. Each rolls its own size and biome, so they range + from sandbars to proper little islands, with a sandy shore and rarely + (`galaxy.mushroom-islet-chance`) mushroom fields. They must be **common + enough to meet by chance while sailing** — an empty sea is the failure mode. + The sea between them carries the vanilla furniture too: shipwrecks, ocean + ruins, monuments in the deep basins, buried treasure on the beaches, trial + chambers in the rock, and caves under the floor. All protection flags default + to allowed outside named islands' protection ranges. Homemade goods are + freely usable (eat, wear, build, burn as warp fuel) but unsellable. - **The connections:** buying (money->stuff, stamped); **contraband** (stuff->money: unstamped sugar - and later villagers - are the only farmable income, balanced by scan risk per principle 3); and self-supply diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5487413..b03e387 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,76 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The sea itself: seabed shape, vanilla structures, denser islets (2026-07-31) + +Ben, after covering 130 regions: "I have not found a single islet. Nothing... +the sea floor is really barren and repetitive." Both were real, and they had +different causes. + +**Islets were generating — just too rarely to meet.** The math was sound (a +probe over the live seed found 357 in a 40,000-block square, the nearest 977 +blocks from spawn) but at grid 1200 x chance 0.3 the mean distance to one was +well over a kilometre, and each was a fixed 70-block cone, so a whole voyage +could pass between two of them and see neither. Now grid 900 x chance 0.55, +and every islet rolls its own radius (0.55x-1.55x the mean) and its own +height, so the sea holds sandbars through to proper little islands. Measured +on the live seed: **mean 544 blocks to the nearest islet, worst case 1,376** +(was well over 2,000). `testIsletsAreFindable` now asserts < 1,200 from four +far-flung points; the old assertion allowed 2,500, which is why it never +caught this. + +**The barren floor was one flat Perlin field**, and worse, it was the wrong +tool: the floor is not scenery, its depth is what picks the ocean biome, and +the biome is what tells vanilla where monuments, ruins and coral belong. So +the seabed moved into the pure galaxy package (`Seabed`, `SeabedConfig`) where +terrain and biome read the same field: +- **Basins** (2,200-block lattice) take the floor from sunlit shelf to abyssal + plain. Depth over the deep-water threshold switches the biome to the + `deep_*` variants — which is the whole unlock for ocean monuments. +- **Relief** (220) rolls dunes and hills over it. +- **Rifts**: ridged noise (value noise folded about its midpoint) cuts narrow + wandering canyons up to 26 blocks below the surrounding floor. +- **Seamounts** rise off the deeper plains only, and are capped so they never + break the surface — the galaxy's islands stay the world's only land. +- **Island shelf blend**: near any island or islet the natural floor eases to + a standard 18-block shelf, so an island that lands over an abyss still sits + in shallow water and still clears the waves by the same amount. A bonus: + the shoreline radius is now exact rather than cut about by floor noise. + +Measured over 53,000 open-sea samples on the live seed: depths spread 8-63, +**39.5% deep water**, and all nine ocean biomes present (frozen through warm, +shallow and deep). + +**Vanilla now furnishes the sea.** `make-caves` and `make-structures` default +true — carvers cut caves under the floor, and structure placement supplies +shipwrecks, ocean ruins (warm and cold, by biome), monuments (deep basins), +buried treasure and trial chambers (in the rock under everything). The hybrid +is: we own the shape of the floor, vanilla furnishes it. Structures are +suppressed per-chunk over trading islands via the per-chunk +`shouldGenerateStructures(WorldInfo, Random, int, int)` overload, so nothing +drops through a market plaza; wild islets are fair game. + +Islets also gained a sandy beach ring at the exact waterline (which is what +lets vanilla beach shipwrecks and bury treasure) and a 6% chance of being +**mushroom fields** — mycelium, mooshrooms, no hostile spawns. + +**Pitfalls:** +- Stacked fbm octaves pull toward the middle: the first cut gave only 16 + blocks of depth variation across a long transect — still barren. The basin + field is now stretched about its midpoint and eased, restoring real shelves + and real abyss. +- Bulk-filling base rock up to the chunk's *lowest* floor top left dead flat + chunks (a market plaza, or level sea floor) as bare stone: every column's + surface loop had zero iterations. The fill now stops 8 blocks short. Caught + by `testPlazaAndDockTerraform` — worth having kept that test honest. +- The seabed hangs off the **galaxy** seed, not the world seed, so one number + still decides the whole world (spec principle 5). `testDeterminism` had to + stub a second engine to prove it. +- Stored config values beat changed code defaults *again* (third time): + `make-structures`/`make-caves` and the islet knobs on the test server were + still the old values. Live config updated by hand. +- 164 tests green. + ## All player-facing text moved into the locale (2026-07-31) Ben's correction: never do MiniMessage conversion by hand - BentoBox's User diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index fc052c0..533a438 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -105,17 +105,25 @@ public class Settings implements WorldSettings { @ConfigComment("Chance (0-1) that a wild-islet grid cell hosts one - small unnamed islands,") @ConfigComment("unprotected: mine, farm, build, live. Minecraft-stuff land.") @ConfigEntry(path = "galaxy.wild-islet-chance", needsReset = true) - private double wildIsletChance = 0.3; + private double wildIsletChance = 0.55; @ConfigComment("Grid size in blocks for wild islets. Much finer than the trading island grid,") - @ConfigComment("so the open sea is dotted with land: at 1200 with chance 0.3 there is usually") - @ConfigComment("an islet within a couple of thousand blocks of anywhere.") + @ConfigComment("so the open sea is dotted with land: at 900 with chance 0.55 there is usually") + @ConfigComment("an islet within a few hundred blocks of anywhere - close enough to row to") + @ConfigComment("without the sea feeling empty.") @ConfigEntry(path = "galaxy.wild-islet-grid", needsReset = true) - private int wildIsletGrid = 1200; + private int wildIsletGrid = 900; - @ConfigComment("Terrain radius of wild islets. 0 disables them.") + @ConfigComment("Mean terrain radius of wild islets. 0 disables them. Each islet rolls its own") + @ConfigComment("size between roughly half and one and a half times this, so the sea holds") + @ConfigComment("everything from sandbars to proper little islands.") @ConfigEntry(path = "galaxy.wild-islet-radius", needsReset = true) - private int wildIsletRadius = 70; + private int wildIsletRadius = 75; + + @ConfigComment("Chance (0-1) that a wild islet is a mushroom island: mycelium, mooshrooms,") + @ConfigComment("and no hostile spawns. Rare enough to be worth the find.") + @ConfigEntry(path = "galaxy.mushroom-islet-chance", needsReset = true) + private double mushroomIsletChance = 0.06; @ConfigComment("Relative spawn weight per island type. Higher = more common; 0 disables a type.") @ConfigComment("Types: AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN.") @@ -606,17 +614,68 @@ private static Map defaultEncounterChance() { @ConfigEntry(path = "world.water-block", needsReset = true) private Material waterBlock = Material.WATER; - @ConfigComment("Allow vanilla cave generation under the ocean floor.") + @ConfigComment("Allow vanilla cave generation: caves, ravines and cheese caverns under the") + @ConfigComment("sea floor, and inside the islands. Dry air pockets down there are a feature -") + @ConfigComment("they are somewhere to surface and something to mine.") @ConfigEntry(path = "world.make-caves") - private boolean makeCaves = false; + private boolean makeCaves = true; - @ConfigComment("Allow vanilla decoration (kelp, seagrass, trees on island land).") + @ConfigComment("Allow vanilla decoration (kelp, seagrass, coral, trees on island land).") @ConfigEntry(path = "world.make-decorations") private boolean makeDecorations = true; - @ConfigComment("Allow vanilla structure generation (shipwrecks, ruins...).") + @ConfigComment("Allow vanilla structure generation: shipwrecks, ocean ruins, ocean monuments,") + @ConfigComment("buried treasure and trial chambers. Which of them appear where is decided by") + @ConfigComment("the biomes the galaxy hands out, so deep basins get monuments, warm shallows") + @ConfigComment("get warm ruins, and islet beaches get treasure.") @ConfigEntry(path = "world.make-structures") - private boolean makeStructures = false; + private boolean makeStructures = true; + + @ConfigComment("Keep vanilla structures off the trading islands themselves. A monument or a") + @ConfigComment("village dropped through a market plaza would wreck the one part of the world") + @ConfigComment("that is hand-built. Wild islets are fair game either way.") + @ConfigEntry(path = "world.keep-structures-off-islands") + private boolean keepStructuresOffIslands = true; + + @ConfigComment("Vary the sea floor. False gives the old featureless flat floor at the shelf") + @ConfigComment("depth; true gives shelves, basins, rifts and seamounts.") + @ConfigEntry(path = "world.seabed.vary", needsReset = true) + private boolean varySeabed = true; + + @ConfigComment("Depth in blocks below sea level of the shallowest open water - the sunlit") + @ConfigComment("banks where coral, kelp and ocean ruins sit.") + @ConfigEntry(path = "world.seabed.shelf-depth", needsReset = true) + private int seabedShelfDepth = 14; + + @ConfigComment("Depth in blocks below sea level of the deepest basins. Deep water gets the") + @ConfigComment("deep ocean biomes, which is what lets vanilla place ocean monuments.") + @ConfigEntry(path = "world.seabed.abyss-depth", needsReset = true) + private int seabedAbyssDepth = 46; + + @ConfigComment("Depth in blocks of the shelf every island and islet sits on. The natural floor") + @ConfigComment("is blended toward this near land, so an island over an abyssal plain still") + @ConfigComment("stands in shallow water and still breaks the surface by the same amount.") + @ConfigEntry(path = "world.seabed.island-shelf-depth", needsReset = true) + private int seabedIslandShelfDepth = 18; + + @ConfigComment("Amplitude in blocks of the rolling hills on top of the basins - the difference") + @ConfigComment("between a dune field and a flat plain.") + @ConfigEntry(path = "world.seabed.relief", needsReset = true) + private int seabedRelief = 9; + + @ConfigComment("How far below the surrounding floor a rift cuts at its deepest, in blocks.") + @ConfigComment("0 disables underwater canyons.") + @ConfigEntry(path = "world.seabed.rift-depth", needsReset = true) + private int seabedRiftDepth = 26; + + @ConfigComment("How rare and narrow the rifts are (0-1). Higher means fewer, tighter canyons.") + @ConfigEntry(path = "world.seabed.rift-threshold", needsReset = true) + private double seabedRiftThreshold = 0.80; + + @ConfigComment("How far a seamount rises above the floor at its peak, in blocks. They only") + @ConfigComment("grow on the deeper plains and never break the surface. 0 disables them.") + @ConfigEntry(path = "world.seabed.seamount-height", needsReset = true) + private int seabedSeamountHeight = 20; @ConfigComment("Maximum number of islands in the world. Set to -1 or 0 for unlimited.") @ConfigComment("If the number of islands is greater than this number, it will stop players from creating islands.") @@ -2415,6 +2474,26 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWildIsletRadius(int wildIsletRadius) { this.wildIsletRadius = wildIsletRadius; } public int getWildIsletGrid() { return wildIsletGrid; } public void setWildIsletGrid(int wildIsletGrid) { this.wildIsletGrid = wildIsletGrid; } + public double getMushroomIsletChance() { return mushroomIsletChance; } + public void setMushroomIsletChance(double mushroomIsletChance) { this.mushroomIsletChance = mushroomIsletChance; } + public boolean isVarySeabed() { return varySeabed; } + public void setVarySeabed(boolean varySeabed) { this.varySeabed = varySeabed; } + public int getSeabedShelfDepth() { return seabedShelfDepth; } + public void setSeabedShelfDepth(int seabedShelfDepth) { this.seabedShelfDepth = seabedShelfDepth; } + public int getSeabedAbyssDepth() { return seabedAbyssDepth; } + public void setSeabedAbyssDepth(int seabedAbyssDepth) { this.seabedAbyssDepth = seabedAbyssDepth; } + public int getSeabedIslandShelfDepth() { return seabedIslandShelfDepth; } + public void setSeabedIslandShelfDepth(int d) { this.seabedIslandShelfDepth = d; } + public int getSeabedRelief() { return seabedRelief; } + public void setSeabedRelief(int seabedRelief) { this.seabedRelief = seabedRelief; } + public int getSeabedRiftDepth() { return seabedRiftDepth; } + public void setSeabedRiftDepth(int seabedRiftDepth) { this.seabedRiftDepth = seabedRiftDepth; } + public double getSeabedRiftThreshold() { return seabedRiftThreshold; } + public void setSeabedRiftThreshold(double t) { this.seabedRiftThreshold = t; } + public int getSeabedSeamountHeight() { return seabedSeamountHeight; } + public void setSeabedSeamountHeight(int h) { this.seabedSeamountHeight = h; } + public boolean isKeepStructuresOffIslands() { return keepStructuresOffIslands; } + public void setKeepStructuresOffIslands(boolean k) { this.keepStructuresOffIslands = k; } public Map getTypeWeights() { return typeWeights; } public void setTypeWeights(Map typeWeights) { this.typeWeights = typeWeights; } public double getFuelPerBlock() { return fuelPerBlock; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 0973cc2..f6487dd 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -59,6 +59,7 @@ import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandType; +import world.bentobox.tradewinds.galaxy.SeabedConfig; import world.bentobox.tradewinds.generator.ChunkGeneratorWorld; import world.bentobox.tradewinds.generator.GalaxyIslandRegistrar; import world.bentobox.tradewinds.generator.TradeWindsBiomeProvider; @@ -501,12 +502,27 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { galaxyEngine = new GalaxyEngine(new GalaxyConfig(seed, s.getGalaxyMinSeparation(), s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight(), typeWeights(), - spawnIslandType(), s.getWildIsletChance(), s.getWildIsletRadius(), s.getWildIsletGrid())); + spawnIslandType(), s.getWildIsletChance(), s.getWildIsletRadius(), s.getWildIsletGrid(), + s.getMushroomIsletChance(), seabedConfig())); log("TradeWinds galaxy seed: " + seed); } return galaxyEngine; } + /** + * The configured shape of the sea floor, or a flat floor at the shelf depth + * when {@code world.seabed.vary} is off. + */ + private SeabedConfig seabedConfig() { + Settings s = getSettings(); + if (!s.isVarySeabed()) { + return SeabedConfig.flat(s.getSeabedShelfDepth()); + } + return new SeabedConfig(s.getSeabedShelfDepth(), s.getSeabedAbyssDepth(), s.getSeabedIslandShelfDepth(), + s.getSeabedRelief(), s.getSeabedRiftDepth(), s.getSeabedRiftThreshold(), + s.getSeabedSeamountHeight()); + } + /** * The configured spawn island economy, or null to let the seed roll it. */ diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java index a3e5955..a4593ef 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -24,25 +24,47 @@ * @param spawnIslandType economy of the trading island reserved at the origin * (null = seeded roll like any other island) * @param wildIsletChance chance (0-1) that a wild-islet grid cell hosts one - * @param wildIsletRadius terrain radius of wild islets (0 = none) + * @param wildIsletRadius mean terrain radius of wild islets (0 = none); each + * islet rolls its own size around this, so the sea holds everything from + * sandbars to proper little islands * @param wildIsletGrid grid size in blocks for wild islets - much finer than the * trading island grid, so the open sea is dotted with land to land on + * @param mushroomIsletChance chance (0-1) that an islet is a mushroom island - + * rare enough to be worth the find + * @param seabed shape of the ocean floor between the islands * * @author tastybento */ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, - IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius, int wildIsletGrid) { + IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius, int wildIsletGrid, + double mushroomIsletChance, SeabedConfig seabed) { /** Default wild islet chance / radius / grid. */ - public static final double DEFAULT_WILD_CHANCE = 0.3; - public static final int DEFAULT_WILD_RADIUS = 70; - public static final int DEFAULT_WILD_GRID = 1200; + public static final double DEFAULT_WILD_CHANCE = 0.55; + public static final int DEFAULT_WILD_RADIUS = 75; + public static final int DEFAULT_WILD_GRID = 900; + /** Default chance that an islet is a mushroom island. */ + public static final double DEFAULT_MUSHROOM_CHANCE = 0.06; public GalaxyConfig { if (typeWeights == null || typeWeights.values().stream().mapToInt(w -> Math.max(0, w)).sum() <= 0) { typeWeights = defaultTypeWeights(); } + if (seabed == null) { + seabed = SeabedConfig.DEFAULT; + } + } + + /** + * Convenience constructor with the default seabed and mushroom chance. + */ + public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, + int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, + IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius, int wildIsletGrid) { + this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, + typeWeights, spawnIslandType, wildIsletChance, wildIsletRadius, wildIsletGrid, + DEFAULT_MUSHROOM_CHANCE, SeabedConfig.DEFAULT); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index 752ac52..364fadf 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -44,6 +44,8 @@ public class GalaxyEngine { private static final long SALT_WILD_X = 0x317DBEA8L; private static final long SALT_WILD_Z = 0x317DBEA9L; private static final long SALT_WILD_BIOME = 0x317DBEAAL; + private static final long SALT_WILD_SIZE = 0x317DBEABL; + private static final long SALT_WILD_MUSHROOM = 0x317DBEACL; private static final long SALT_OCEAN_TEMP = 0x0CEA17E1L; /** @@ -60,11 +62,38 @@ public class GalaxyEngine { private static final List OCEAN_BIOMES = List.of("minecraft:frozen_ocean", "minecraft:cold_ocean", "minecraft:ocean", "minecraft:lukewarm_ocean", "minecraft:warm_ocean"); + /** + * The deep-water counterpart of each entry in {@link #OCEAN_BIOMES}, in the + * same temperature order. Depth picks between the two lists, and that is + * what tells vanilla where ocean monuments belong - there is no deep warm + * ocean in Minecraft, so warm water deepens into lukewarm. + */ + private static final List DEEP_OCEAN_BIOMES = List.of("minecraft:deep_frozen_ocean", + "minecraft:deep_cold_ocean", "minecraft:deep_ocean", "minecraft:deep_lukewarm_ocean", + "minecraft:deep_lukewarm_ocean"); + + /** + * Where the shelf ends and deep water begins, as a fraction of the way from + * the shelf depth to the abyss depth. + */ + private static final double DEEP_WATER_FRACTION = 0.6; + /** Vanilla biomes wild islets draw from - Minecraft-stuff land. */ private static final List WILD_BIOMES = List.of("minecraft:plains", "minecraft:forest", "minecraft:birch_forest", "minecraft:jungle", "minecraft:savanna", "minecraft:swamp", "minecraft:flower_forest", "minecraft:dark_forest"); + /** The rare islet biome - no hostile spawns, mycelium, mooshrooms. */ + public static final String MUSHROOM_BIOME = "minecraft:mushroom_fields"; + /** Sandy fringes: where beached shipwrecks and buried treasure belong. */ + private static final String BEACH_BIOME = "minecraft:beach"; + private static final String SNOWY_BEACH_BIOME = "minecraft:snowy_beach"; + /** Half-width of an islet's beach ring, in blocks, either side of the shoreline. */ + private static final int BEACH_RING = 7; + /** Smallest and largest islet radius as a fraction of the configured mean. */ + private static final double ISLET_MIN_SCALE = 0.55; + private static final double ISLET_MAX_SCALE = 1.55; + // Dock/plaza geometry, as fractions of the terrain radius. The plaza sits // at ~45% out (where natural terrain is already near sea level, so the // flattening cuts little), the quay runs from the plaza to 85% (open water). @@ -80,10 +109,21 @@ public class GalaxyEngine { private final GalaxyConfig config; private final Set starterCells; private final Map> cache = new ConcurrentHashMap<>(); + private final Seabed seabed; public GalaxyEngine(GalaxyConfig config) { this.config = config; this.starterCells = computeStarterCells(); + this.seabed = new Seabed(config.seed(), config.seaLevel(), config.seabed()); + } + + /** + * The ocean floor field - basins, relief, rifts and seamounts. + * + * @return this galaxy's seabed + */ + public Seabed getSeabed() { + return seabed; } public GalaxyConfig getConfig() { @@ -275,25 +315,43 @@ public int landLiftAt(int blockX, int blockZ) { best = Math.max(best, mask); } } - // Wild islets: free land between the trading islands - Optional wild = wildIsletAt(blockX, blockZ); + // Wild islets: free land between the trading islands. Small islets are + // lower as well as narrower, so a sandbar is a sandbar and not a spike. + Optional wild = isletAt(blockX, blockZ); if (wild.isPresent()) { - double d = Math.hypot((double) blockX - wild.get()[0], (double) blockZ - wild.get()[1]); - best = Math.max(best, 0.5 * (1 + Math.cos(Math.PI * d / config.wildIsletRadius()))); + Islet islet = wild.get(); + double d = Math.sqrt(islet.distanceSquared(blockX, blockZ)); + double mask = 0.5 * (1 + Math.cos(Math.PI * d / islet.radius())); + return (int) Math.round(config.landLift() * mask * isletHeightScale(islet)); } return (int) Math.round(config.landLift() * best); } + /** + * How tall an islet stands relative to a trading island, from its size: big + * islets rise the full land lift, sandbars barely clear the water. + * + * @param islet the islet + * @return scale factor for the land lift + */ + private double isletHeightScale(Islet islet) { + if (config.wildIsletRadius() <= 0) { + return 1.0; + } + return Math.clamp((double) islet.radius() / config.wildIsletRadius(), 0.5, 1.15); + } + /** * The wild islet hosted by a cell, if any: cells without a trading island * may roll a small unnamed island - free land for mining, farming and - * building ("Minecraft stuff"), and claim material for later stages. + * building ("Minecraft stuff"), and claim material for later stages. Each + * rolls its own radius and biome, so no two look alike. * * @param cellX cell x * @param cellZ cell z - * @return {centerX, centerZ} or empty + * @return the islet, or empty */ - public Optional wildIsletInCell(int cellX, int cellZ) { + public Optional wildIsletInCell(int cellX, int cellZ) { if (config.wildIsletChance() <= 0 || config.wildIsletRadius() <= 0) { return Optional.empty(); } @@ -306,14 +364,30 @@ public Optional wildIsletInCell(int cellX, int cellZ) { double jz = Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_Z)) * 2 - 1; int x = (int) Math.round((cellX + 0.5) * size + jx * jitter); int z = (int) Math.round((cellZ + 0.5) * size + jz * jitter); + double scale = ISLET_MIN_SCALE + Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_SIZE)) + * (ISLET_MAX_SCALE - ISLET_MIN_SCALE); + int radius = Math.max(20, (int) Math.round(config.wildIsletRadius() * scale)); // Never crowd a trading island: its terrain, its dock and a margin - int clearance = config.terrainRadius() + config.wildIsletRadius() + 80; + int clearance = config.terrainRadius() + radius + 80; for (IslandSpec spec : islandsNear(x, z, clearance)) { if (spec.distanceSquared(x, z) < (long) clearance * clearance) { return Optional.empty(); } } - return Optional.of(new int[] { x, z }); + return Optional.of(new Islet(x, z, radius, isletBiome(cellX, cellZ))); + } + + /** + * A wild islet's whole-island biome (seeded from its cell). Mostly ordinary + * vanilla land; rarely, mushroom fields. + */ + private String isletBiome(int cellX, int cellZ) { + if (Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, + SALT_WILD_MUSHROOM)) < config.mushroomIsletChance()) { + return MUSHROOM_BIOME; + } + long hash = Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_BIOME); + return WILD_BIOMES.get((int) Math.floorMod(hash, WILD_BIOMES.size())); } /** @@ -321,23 +395,35 @@ public Optional wildIsletInCell(int cellX, int cellZ) { * * @param blockX block x * @param blockZ block z - * @return {centerX, centerZ} or empty + * @return the islet, or empty */ - public Optional wildIsletAt(int blockX, int blockZ) { - int radius = config.wildIsletRadius(); - if (radius <= 0) { + public Optional isletAt(int blockX, int blockZ) { + return isletNear(blockX, blockZ, 1.0); + } + + /** + * The wild islet whose footprint - optionally widened - covers a column. + * + * @param blockX block x + * @param blockZ block z + * @param scale multiplier on the islet radius; 1.0 is the islet itself, 2.0 + * includes its surrounding shelf + * @return the islet, or empty + */ + private Optional isletNear(int blockX, int blockZ, double scale) { + if (config.wildIsletRadius() <= 0) { return Optional.empty(); } int size = config.wildIsletGrid(); int cellX = Math.floorDiv(blockX, size); int cellZ = Math.floorDiv(blockZ, size); + // A big islet's shelf can reach past its own cell, so check the ring for (int cx = cellX - 1; cx <= cellX + 1; cx++) { for (int cz = cellZ - 1; cz <= cellZ + 1; cz++) { - Optional islet = wildIsletInCell(cx, cz); + Optional islet = wildIsletInCell(cx, cz); if (islet.isPresent()) { - long dx = (long) blockX - islet.get()[0]; - long dz = (long) blockZ - islet.get()[1]; - if (dx * dx + dz * dz <= (long) radius * radius) { + long reach = Math.round(islet.get().radius() * scale); + if (islet.get().distanceSquared(blockX, blockZ) <= reach * reach) { return islet; } } @@ -347,13 +433,25 @@ public Optional wildIsletAt(int blockX, int blockZ) { } /** - * A wild islet's whole-island biome (seeded from its cell). + * The distance from an islet's center at which its land meets the water - + * exactly, because the shelf under an islet is flat by construction. The + * beach ring and the sand surface hang off this. + * + * @param islet the islet + * @return shoreline radius in blocks, 0 if the islet never breaks the surface */ - public String wildIsletBiome(int centerX, int centerZ) { - int size = config.wildIsletGrid(); - long hash = Hashing.cellHash(config.seed(), Math.floorDiv(centerX, size), Math.floorDiv(centerZ, size), - SALT_WILD_BIOME); - return WILD_BIOMES.get((int) Math.floorMod(hash, WILD_BIOMES.size())); + public double isletShoreRadius(Islet islet) { + double lift = config.landLift() * isletHeightScale(islet); + if (lift <= 0) { + return 0; + } + // Land where lift * mask > shelf depth; invert the cosine mask for the + // exact crossing rather than sampling for it + double mask = config.seabed().islandShelfDepth() / lift; + if (mask >= 1.0) { + return 0; + } + return islet.radius() / Math.PI * Math.acos(Math.clamp(2 * mask - 1, -1.0, 1.0)); } /** @@ -431,18 +529,155 @@ public Optional columnPlanAt(int blockX, int blockZ) { * @return an ocean biome key */ public String oceanBiomeKeyAt(int blockX, int blockZ) { + int index = oceanTemperatureIndex(blockX, blockZ); + return isDeepWater(blockX, blockZ) ? DEEP_OCEAN_BIOMES.get(index) : OCEAN_BIOMES.get(index); + } + + /** + * The sea's temperature step at a position: 0 frozen through to 4 warm. + * Because the field is continuous and this mapping is monotonic, adjacent + * water can only differ by one step. + * + * @param blockX block x + * @param blockZ block z + * @return index into the ordered ocean biome lists + */ + public int oceanTemperatureIndex(int blockX, int blockZ) { double temperature = Noise.at(config.seed(), SALT_OCEAN_TEMP, blockX, blockZ, OCEAN_TEMPERATURE_SCALE); - int index = Math.clamp((int) (temperature * OCEAN_BIOMES.size()), 0, OCEAN_BIOMES.size() - 1); - return OCEAN_BIOMES.get(index); + return Math.clamp((int) (temperature * OCEAN_BIOMES.size()), 0, OCEAN_BIOMES.size() - 1); + } + + /** + * Whether a column stands over deep water: dark, cold, and the only place + * vanilla will put an ocean monument. + * + * @param blockX block x + * @param blockZ block z + * @return true over the deep basins + */ + public boolean isDeepWater(int blockX, int blockZ) { + SeabedConfig sb = config.seabed(); + if (sb.abyssDepth() <= sb.shelfDepth()) { + return false; // A flat sea floor is never "deep" - it is all one shelf + } + double threshold = sb.shelfDepth() + (sb.abyssDepth() - sb.shelfDepth()) * DEEP_WATER_FRACTION; + return config.seaLevel() - seabedHeightAt(blockX, blockZ) >= threshold; + } + + /** + * The Y of the topmost sea-floor block at a column, before any island lift: + * the natural floor in open water, eased toward the island shelf near land. + * + * @param blockX block x + * @param blockZ block z + * @return the floor's top Y + */ + public int seabedHeightAt(int blockX, int blockZ) { + return seabed.heightAt(blockX, blockZ, shelfBlendAt(blockX, blockZ)); + } + + /** + * How much a column belongs to an island's shelf rather than the open sea: + * 1 inside a footprint, easing to 0 at twice its radius. This is what keeps + * an island that happens to sit over an abyssal plain in shallow water. + * + * @param blockX block x + * @param blockZ block z + * @return blend in [0, 1] + */ + public double shelfBlendAt(int blockX, int blockZ) { + double best = 0; + int radius = config.terrainRadius(); + for (IslandSpec s : islandsNear(blockX, blockZ, radius * 2)) { + best = Math.max(best, shelfTaper(Math.sqrt(s.distanceSquared(blockX, blockZ)), radius)); + } + Optional islet = isletNear(blockX, blockZ, 2.0); + if (islet.isPresent()) { + best = Math.max(best, + shelfTaper(Math.sqrt(islet.get().distanceSquared(blockX, blockZ)), islet.get().radius())); + } + return best; + } + + /** + * Full shelf inside the footprint, cosine-eased to open sea at twice it. + */ + private static double shelfTaper(double distance, int radius) { + if (distance <= radius) { + return 1.0; + } + if (distance >= radius * 2.0) { + return 0.0; + } + return 0.5 * (1 + Math.cos(Math.PI * (distance - radius) / radius)); + } + + /** + * What the top block of a land column should be. Islets get a sandy fringe + * at the waterline (and mycelium all over, if they are mushroom islands); + * everything else is ordinary grass. + * + * @param blockX block x + * @param blockZ block z + * @return the surface kind + */ + public SurfaceKind surfaceKindAt(int blockX, int blockZ) { + Optional islet = isletAt(blockX, blockZ); + if (islet.isEmpty()) { + return SurfaceKind.GRASS; + } + if (islet.get().isMushroom()) { + return SurfaceKind.MYCELIUM; + } + double shore = isletShoreRadius(islet.get()); + double d = Math.sqrt(islet.get().distanceSquared(blockX, blockZ)); + return shore > 0 && d >= shore - BEACH_RING ? SurfaceKind.SAND : SurfaceKind.GRASS; } /** * Every ocean biome the sea can take - the world must declare them all. */ public static List oceanBiomes() { + List all = new ArrayList<>(OCEAN_BIOMES); + DEEP_OCEAN_BIOMES.stream().filter(k -> !all.contains(k)).forEach(all::add); + all.add(BEACH_BIOME); + all.add(SNOWY_BEACH_BIOME); + all.add(MUSHROOM_BIOME); + return List.copyOf(all); + } + + /** + * Every land biome a wild islet can take - the world must declare these too. + * + * @return the islet biomes + */ + public static List isletBiomes() { + List all = new ArrayList<>(WILD_BIOMES); + all.add(MUSHROOM_BIOME); + all.add(BEACH_BIOME); + all.add(SNOWY_BEACH_BIOME); + return List.copyOf(all); + } + + /** + * The ocean biomes in temperature order, shallow water only - the ordering + * the "never jump more than one step" invariant is checked against. + * + * @return the shallow ocean biomes, coldest first + */ + public static List shallowOceanBiomes() { return OCEAN_BIOMES; } + /** + * The deep-water counterparts, in the same temperature order. + * + * @return the deep ocean biomes, coldest first + */ + public static List deepOceanBiomes() { + return DEEP_OCEAN_BIOMES; + } + /** * The biome key governing a column, or empty for default ocean handling. * Within an island's terrain radius this is the island biome; in the @@ -454,9 +689,9 @@ public static List oceanBiomes() { * @return biome key, or empty for open ocean */ public Optional biomeKeyAt(int blockX, int blockZ) { - Optional wild = wildIsletAt(blockX, blockZ); + Optional wild = isletAt(blockX, blockZ); if (wild.isPresent()) { - return Optional.of(wildIsletBiome(wild.get()[0], wild.get()[1])); + return Optional.of(isletBiomeAt(wild.get(), blockX, blockZ)); } int radius = config.terrainRadius(); long r2 = (long) radius * radius; @@ -473,4 +708,29 @@ public Optional biomeKeyAt(int blockX, int blockZ) { } return ring; } + + /** + * The biome of one column of an islet: its own biome inland, and a beach at + * the waterline - which is what lets vanilla wash up beached shipwrecks and + * bury treasure there. Mushroom islands have no beach (nor does vanilla). + * + * @param islet the islet + * @param blockX block x + * @param blockZ block z + * @return the biome key + */ + private String isletBiomeAt(Islet islet, int blockX, int blockZ) { + if (islet.isMushroom()) { + return islet.biomeKey(); + } + double shore = isletShoreRadius(islet); + if (shore > 0) { + double d = Math.sqrt(islet.distanceSquared(blockX, blockZ)); + if (d >= shore - BEACH_RING && d <= shore + BEACH_RING) { + // Cold seas get a snowy shore, so the shoreline matches its water + return oceanTemperatureIndex(blockX, blockZ) <= 1 ? SNOWY_BEACH_BIOME : BEACH_BIOME; + } + } + return islet.biomeKey(); + } } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/Islet.java b/src/main/java/world/bentobox/tradewinds/galaxy/Islet.java new file mode 100644 index 0000000..ebba062 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/Islet.java @@ -0,0 +1,43 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * A wild islet: unowned, unnamed land between the trading islands. Free to + * mine, farm, build on and live on - the "Minecraft stuff" half of the game + * (spec section 5.0, the two economies). + *

+ * Unlike trading islands these vary in size and in biome, so the sea is dotted + * with anything from a sandbar with three trees on it to a proper little + * island with a cave system under it. + * + * @param centerX block x of the islet's center + * @param centerZ block z of the islet's center + * @param radius terrain radius in blocks + * @param biomeKey the islet's whole-island biome key + * + * @author tastybento + */ +public record Islet(int centerX, int centerZ, int radius, String biomeKey) { + + /** + * Squared distance from a block position to this islet's center. + * + * @param blockX block x + * @param blockZ block z + * @return squared distance in blocks + */ + public long distanceSquared(int blockX, int blockZ) { + long dx = (long) blockX - centerX; + long dz = (long) blockZ - centerZ; + return dx * dx + dz * dz; + } + + /** + * Whether this islet is a rare mushroom island - no hostile spawns, mycelium + * underfoot, and mooshrooms if vanilla decoration is on. + * + * @return true for mushroom fields + */ + public boolean isMushroom() { + return GalaxyEngine.MUSHROOM_BIOME.equals(biomeKey); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/Noise.java b/src/main/java/world/bentobox/tradewinds/galaxy/Noise.java index a3fba17..e699703 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/Noise.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/Noise.java @@ -38,6 +38,57 @@ public static double at(long seed, long salt, double x, double z, int lattice) { return lerp(lerp(v00, v10, fx), lerp(v01, v11, fx), fz); } + /** + * Fractal (multi-octave) noise in [0, 1]: each octave halves the lattice and + * shrinks its contribution, so one call gives broad shapes with fine detail + * riding on top. This is what turns a flat noise field into terrain. + * + * @param seed galaxy seed + * @param salt distinguishes independent fields + * @param x world x + * @param z world z + * @param lattice spacing of the first (broadest) octave, in blocks + * @param octaves number of octaves, at least 1 + * @param persistence how much of the previous octave's amplitude each + * successive octave keeps - 0.5 is the usual choice + * @return value in [0, 1] + */ + public static double fbm(long seed, long salt, double x, double z, int lattice, int octaves, + double persistence) { + double total = 0; + double amplitude = 1; + double sum = 0; + int span = lattice; + for (int i = 0; i < Math.max(1, octaves); i++) { + total += at(seed, salt + i * 0x9E3779B9L, x, z, Math.max(1, span)) * amplitude; + sum += amplitude; + amplitude *= persistence; + span /= 2; + } + return total / sum; + } + + /** + * Ridged noise in [0, 1]: value noise folded about its midpoint so the field + * peaks along thin lines rather than in broad blobs. Sea-floor rifts and + * canyons are cut where this runs high - folding is what makes them long and + * narrow instead of round. + * + * @param seed galaxy seed + * @param salt distinguishes independent fields + * @param x world x + * @param z world z + * @param lattice spacing of the lattice in blocks + * @return value in [0, 1], 1 along the ridge lines + */ + public static double ridge(long seed, long salt, double x, double z, int lattice) { + // Two octaves: the second breaks up the first's regularity so canyons + // wander and branch instead of running dead straight + double a = 1 - Math.abs(2 * at(seed, salt, x, z, lattice) - 1); + double b = 1 - Math.abs(2 * at(seed, salt + 0x5DEECE66L, x, z, Math.max(1, lattice / 3)) - 1); + return a * 0.75 + b * 0.25; + } + private static double corner(long seed, long salt, int x, int z) { return Hashing.toUnit(Hashing.cellHash(seed, x, z, salt)); } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java b/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java new file mode 100644 index 0000000..83bb5b9 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java @@ -0,0 +1,200 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * The ocean floor as a pure function of (seed, position) - no Bukkit, unit + * tested headlessly like the rest of the galaxy (spec principle 5). + *

+ * Four fields stack to make a sea worth diving in: + *

    + *
  1. Basins - a broad, slow field taking the floor from sunlit shelf to + * abyssal plain over a few thousand blocks. This is the one that matters most: + * depth picks the ocean biome, and the biome is what lets vanilla decide where + * monuments, ruins and coral belong.
  2. + *
  3. Relief - rolling hills and dunes at a scale you notice while + * swimming.
  4. + *
  5. Rifts - ridged noise folded to a thin line, cutting narrow + * canyons that wander across the floor and drop away sharply.
  6. + *
  7. Seamounts - underwater peaks rising off the deeper plains, capped + * so they never break the surface (the galaxy's islands are the world's only + * land, spec principle 6).
  8. + *
+ * Near land all of that is blended away toward a standard island shelf, so an + * island that happens to fall over an abyssal plain still sits in shallow water + * and still breaks the surface by the same amount. + * + * @author tastybento + */ +public class Seabed { + + // Independent fields, each with its own salt + private static final long SALT_BASIN = 0x5EABED01L; + private static final long SALT_RELIEF = 0x5EABED02L; + private static final long SALT_RIFT = 0x5EABED03L; + private static final long SALT_SEAMOUNT = 0x5EABED04L; + private static final long SALT_SEDIMENT = 0x5EABED05L; + + /** Scale of the basins, in blocks: a long swim from shelf to deep water. */ + private static final int BASIN_LATTICE = 2200; + /** Scale of the rolling relief, in blocks. */ + private static final int RELIEF_LATTICE = 220; + /** Scale of the rift network, in blocks. */ + private static final int RIFT_LATTICE = 900; + /** Scale of the seamount field, in blocks. */ + private static final int SEAMOUNT_LATTICE = 1400; + /** Scale of the sediment patches (sand, gravel, clay), in blocks. */ + private static final int SEDIMENT_LATTICE = 26; + + /** + * Minimum water depth over open sea. Seamounts and shoals stop here so no + * accidental land appears between the islands. + */ + private static final int MINIMUM_WATER = 4; + + private final long seed; + private final SeabedConfig config; + private final int seaLevel; + + public Seabed(long seed, int seaLevel, SeabedConfig config) { + this.seed = seed; + this.seaLevel = seaLevel; + this.config = config == null ? SeabedConfig.DEFAULT : config; + } + + public SeabedConfig getConfig() { + return config; + } + + /** + * The natural depth of the open sea at a column, ignoring land: the basin + * field plus relief, with rifts cut and seamounts raised. + * + * @param blockX block x + * @param blockZ block z + * @return depth in blocks below sea level, at least {@link #MINIMUM_WATER} + */ + public double openSeaDepth(int blockX, int blockZ) { + double basin = basinAt(blockX, blockZ); + double depth = config.shelfDepth() + basin * (config.abyssDepth() - config.shelfDepth()); + // Rolling relief, centered so it neither raises nor lowers on average + depth += (Noise.fbm(seed, SALT_RELIEF, blockX, blockZ, RELIEF_LATTICE, 3, 0.5) - 0.5) * 2 * config.relief(); + depth += riftCut(blockX, blockZ); + depth -= seamountRise(blockX, blockZ, basin); + // Open water everywhere: a shoal that reached the surface would be land + // the galaxy never placed (spec principle 6) + return Math.max(MINIMUM_WATER, depth); + } + + /** + * The basin field at a column: 0 on the shallowest banks, 1 on the deepest + * plains. + *

+ * Stacking octaves pulls values toward the middle, which left the first cut + * of this a narrow band of samey mid-depth water - the "barren and + * repetitive" sea floor. Stretching the field about its midpoint and then + * easing it restores real shelves and real abyss, with the transition + * between them happening over a slope rather than everywhere at once. + * + * @param blockX block x + * @param blockZ block z + * @return value in [0, 1] + */ + double basinAt(int blockX, int blockZ) { + double raw = Noise.fbm(seed, SALT_BASIN, blockX, blockZ, BASIN_LATTICE, 3, 0.5); + double stretched = Math.clamp((raw - 0.5) * 2.0 + 0.5, 0.0, 1.0); + return stretched * stretched * (3 - 2 * stretched); + } + + /** + * How much deeper a rift makes this column. Zero outside the rift network; + * inside, it eases in from the threshold so canyon walls are steep but not + * a sheer one-block cliff. + * + * @param blockX block x + * @param blockZ block z + * @return extra depth in blocks, 0 or more + */ + double riftCut(int blockX, int blockZ) { + if (config.riftDepth() <= 0 || config.riftThreshold() >= 1.0) { + return 0; + } + double ridge = Noise.ridge(seed, SALT_RIFT, blockX, blockZ, RIFT_LATTICE); + if (ridge <= config.riftThreshold()) { + return 0; + } + double t = (ridge - config.riftThreshold()) / (1.0 - config.riftThreshold()); + // Squared so the canyon floor is narrow and its walls fall away fast + return config.riftDepth() * t * t; + } + + /** + * How high a seamount rises here. They only grow out of the deeper half of + * the basin field - a pinnacle on a shallow bank would just be an island. + * + * @param blockX block x + * @param blockZ block z + * @param basin the basin field value at this column, 0 shallow to 1 deep + * @return height in blocks, 0 or more + */ + double seamountRise(int blockX, int blockZ, double basin) { + if (config.seamountHeight() <= 0 || basin < 0.5) { + return 0; + } + double ridge = Noise.ridge(seed, SALT_SEAMOUNT, blockX, blockZ, SEAMOUNT_LATTICE); + if (ridge <= 0.75) { + return 0; + } + double t = (ridge - 0.75) / 0.25; + // Fade in with basin depth too, so peaks sit on the plains not the edges + return config.seamountHeight() * t * t * Math.clamp((basin - 0.5) * 2, 0, 1); + } + + /** + * The floor's top Y at a column, given how close it is to land. + * + * @param blockX block x + * @param blockZ block z + * @param shelfBlend 0 in open ocean, 1 on an island's own shelf; between + * the two the natural floor eases toward the island shelf depth + * @return the Y of the topmost floor block + */ + public int heightAt(int blockX, int blockZ, double shelfBlend) { + double depth = openSeaDepth(blockX, blockZ); + if (shelfBlend > 0) { + double blend = Math.clamp(shelfBlend, 0, 1); + depth = depth + (config.islandShelfDepth() - depth) * blend; + } + return seaLevel - (int) Math.round(depth); + } + + /** + * The shallowest the floor can ever be in open water - the generator uses + * this to know that open sea is always open sea. + * + * @return minimum floor Y + */ + public int maxFloorY() { + return seaLevel - MINIMUM_WATER; + } + + /** + * The deepest the floor can ever be, so the generator knows how much solid + * rock to lay down underneath it. + * + * @return minimum floor Y + */ + public int minFloorY() { + return seaLevel - config.maxDepth() - config.relief(); + } + + /** + * A slow patch field used to pick sediment: sand banks, gravel beds and + * clay pans in broad patches rather than block-by-block static. + * + * @param blockX block x + * @param blockZ block z + * @return value in [0, 1] + */ + public double sedimentAt(int blockX, int blockZ) { + return Noise.fbm(seed, SALT_SEDIMENT, blockX, blockZ, SEDIMENT_LATTICE, 2, 0.5); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/SeabedConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/SeabedConfig.java new file mode 100644 index 0000000..b4533b4 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/SeabedConfig.java @@ -0,0 +1,65 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * Shape of the ocean floor. All depths are in blocks below sea level, so the + * numbers read the way a chart does: bigger means deeper water. + * + * @param shelfDepth depth of the shallowest open water - sunlit banks where + * coral, kelp and ocean ruins sit + * @param abyssDepth depth of the deepest basins - dark water, and where ocean + * monuments can generate + * @param islandShelfDepth depth of the shelf every trading island and islet + * sits on; the natural floor is blended toward this near land so islands + * always break the surface by the same amount wherever the basins fall + * @param relief amplitude in blocks of the rolling hills riding on top of the + * basin field - the difference between a dune field and a flat plain + * @param riftDepth how far below the surrounding floor a rift cuts at its + * deepest, in blocks; 0 disables rifts + * @param riftThreshold ridge value (0-1) above which a rift starts to open - + * higher means rarer and narrower canyons + * @param seamountHeight how far a seamount rises above the surrounding floor at + * its peak, in blocks; 0 disables them. Seamounts never break the + * surface - the generator keeps open water above them + * + * @author tastybento + */ +public record SeabedConfig(int shelfDepth, int abyssDepth, int islandShelfDepth, int relief, int riftDepth, + double riftThreshold, int seamountHeight) { + + /** The default sea floor: shelves at 14 down to basins at 46. */ + public static final SeabedConfig DEFAULT = new SeabedConfig(14, 46, 18, 9, 26, 0.80, 20); + + /** + * A flat floor at a fixed depth - the pre-Stage-6 ocean, and what + * {@code world.seabed.vary: false} gives you. + * + * @param depth depth below sea level + * @return a featureless seabed config + */ + public static SeabedConfig flat(int depth) { + return new SeabedConfig(depth, depth, depth, 0, 0, 1.0, 0); + } + + public SeabedConfig { + // A floor that rises above the waves would make land outside the + // galaxy's islands (spec principle 6), so depths are kept positive and + // ordered shallow-to-deep + shelfDepth = Math.max(1, shelfDepth); + abyssDepth = Math.max(shelfDepth, abyssDepth); + islandShelfDepth = Math.max(1, islandShelfDepth); + relief = Math.max(0, relief); + riftDepth = Math.max(0, riftDepth); + riftThreshold = Math.clamp(riftThreshold, 0.0, 1.0); + seamountHeight = Math.max(0, seamountHeight); + } + + /** + * The deepest the floor can ever be, in blocks below sea level - a rift cut + * into the deepest basin. The generator fills solid rock below this. + * + * @return maximum depth in blocks + */ + public int maxDepth() { + return abyssDepth + riftDepth; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/SurfaceKind.java b/src/main/java/world/bentobox/tradewinds/galaxy/SurfaceKind.java new file mode 100644 index 0000000..22b37ce --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/SurfaceKind.java @@ -0,0 +1,16 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * What the top block of a land column should be, expressed without Bukkit so + * the galaxy stays pure. {@code IslandPalette} maps these to materials. + * + * @author tastybento + */ +public enum SurfaceKind { + /** Ordinary land: grass, and whatever vanilla decoration plants on it. */ + GRASS, + /** The sandy fringe of an islet - where beached shipwrecks and buried treasure belong. */ + SAND, + /** A mushroom island's mycelium. */ + MYCELIUM +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index b8c0905..a77b0cd 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -12,51 +12,60 @@ import org.bukkit.generator.BlockPopulator; import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.WorldInfo; -import org.bukkit.util.noise.PerlinOctaveGenerator; import org.eclipse.jdt.annotation.NonNull; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.galaxy.ColumnPlan; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.Seabed; +import world.bentobox.tradewinds.galaxy.SeabedConfig; +import world.bentobox.tradewinds.galaxy.SurfaceKind; /** - * Generates the TradeWinds ocean: a noised ocean floor under open sea, everywhere. + * Generates the TradeWinds ocean: a sea floor of shelves, basins, rifts and + * seamounts under open water, everywhere. *

- * Both the overworld and the interstice (NETHER environment) use this generator; - * only the sea levels, water block and floor palette differ. Vanilla noise is off - * so no vanilla continents can appear: from Stage 1 the only land in the world - * comes from the seeded galaxy's radial island masks via {@link #terrainScale}. + * Vanilla noise is off so no vanilla continents can appear - the only land in + * the world comes from the seeded galaxy's radial island masks. Everything + * else vanilla offers is left switched on: its carvers cut the caves under the + * sea floor, and its structure placement supplies the shipwrecks, ocean ruins, + * monuments and trial chambers, guided entirely by the biomes the galaxy hands + * out. That is the hybrid: we own the shape of the floor, vanilla furnishes it. + *

+ * Both the overworld and the interstice (NETHER environment) use this + * generator; the interstice gets its own drab, shallow sea floor and none of + * the galaxy. * * @author tastybento */ public class ChunkGeneratorWorld extends ChunkGenerator { /** - * Floor palette: base fill under the surface layer, and the two materials the - * noised floor surface is randomly built from. + * Per-environment sea shape. */ - private record FloorMats(Material deepBase, Material base, Material top) { + private record WorldConfig(int seaHeight, int seaFloor, Material waterBlock) { } /** - * Per-environment sea shape. + * The interstice's sea floor: shallower, tighter, and no seamounts - it is + * meant to feel like a dead end, not a place to explore. */ - private record WorldConfig(int seaHeight, int seaFloor, Material waterBlock) { - } + private static final SeabedConfig INTERSTICE_SEABED = new SeabedConfig(10, 26, 10, 6, 12, 0.85, 0); + /** Salt so the interstice floor does not mirror the overworld's. */ + private static final long INTERSTICE_SALT = 0x1E7E2571CEL; - /** Maximum height variation of the ocean floor noise, in blocks. */ - private static final int NOISE_MAX = 25; - private static final double NOISE_SCALE = 1.0 / 30.0; - private static final int NOISE_OCTAVES = 8; + /** Depth below sea level at which sediment gives way to bare rock. */ + private static final int ROCK_DEPTH = 34; + /** Thickness of the sediment layer over the rock, in blocks. */ + private static final int SEDIMENT_THICKNESS = 4; + /** How far the varied rock reaches below the floor before it is all stone. */ + private static final int ROCK_BAND = 8; + /** Soil depth under a land surface before it turns to stone. */ + private static final int SOIL_THICKNESS = 4; private final TradeWinds addon; private final Map seaConfig = new EnumMap<>(Environment.class); - private static final Map FLOOR_MATS = Map.of( - Environment.NORMAL, new FloorMats(Material.STONE, Material.SANDSTONE, Material.SAND), - Environment.NETHER, new FloorMats(Material.NETHERRACK, Material.BASALT, Material.SOUL_SAND)); - - private final Map noiseGens = new EnumMap<>(Environment.class); - // Deterministic palette randomness; re-seeded per chunk from (world seed, chunk coords) - private final Random rand = new Random(); + private final Map seabeds = new EnumMap<>(Environment.class); private IslandDecorator decorator; public ChunkGeneratorWorld(TradeWinds addon) { @@ -86,6 +95,23 @@ protected int terrainLift(WorldInfo worldInfo, int worldX, int worldZ) { return addon.getGalaxyEngine(worldInfo.getSeed()).landLiftAt(worldX, worldZ); } + /** + * The sea floor field for an environment. The overworld shares the galaxy's + * so terrain and biomes agree on where the deep water is; the interstice + * gets its own. + */ + private Seabed seabed(WorldInfo worldInfo) { + return seabeds.computeIfAbsent(worldInfo.getEnvironment(), env -> { + if (env == Environment.NORMAL) { + return addon.getGalaxyEngine(worldInfo.getSeed()).getSeabed(); + } + return new Seabed(worldInfo.getSeed() ^ INTERSTICE_SALT, + seaConfig.get(Environment.NETHER).seaHeight(), + addon.getSettings().isVarySeabed() ? INTERSTICE_SEABED + : SeabedConfig.flat(INTERSTICE_SEABED.shelfDepth())); + }); + } + @Override public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, int chunkX, int chunkZ, @NonNull ChunkData chunkData) { @@ -93,78 +119,159 @@ public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, if (wc == null) { return; // Only NORMAL and NETHER are ever created } - FloorMats mats = FLOOR_MATS.get(worldInfo.getEnvironment()); - PerlinOctaveGenerator gen = noiseGens.computeIfAbsent(worldInfo.getEnvironment(), env -> { - PerlinOctaveGenerator g = new PerlinOctaveGenerator(worldInfo.getSeed(), NOISE_OCTAVES); - g.setScale(NOISE_SCALE); - return g; - }); - // Deterministic per-chunk palette randomness so regeneration is identical - rand.setSeed(worldInfo.getSeed() ^ (chunkX * 341873128712L + chunkZ * 132897987541L)); + boolean overworld = worldInfo.getEnvironment() == Environment.NORMAL; + Seabed floor = seabed(worldInfo); + GalaxyEngine engine = overworld ? addon.getGalaxyEngine(worldInfo.getSeed()) : null; int minHeight = worldInfo.getMinHeight(); // Bedrock floor chunkData.setRegion(0, minHeight, 0, 16, minHeight + 1, 16, Material.BEDROCK); - // Solid base up to the sea floor - if (wc.seaFloor() > minHeight + 1) { - chunkData.setRegion(0, minHeight + 1, 0, 16, wc.seaFloor(), 16, mats.deepBase()); + + // Sound the whole chunk first, so the solid rock underneath everything + // can go in as one region fill rather than block by block - with rifts + // reaching this far down, that is the difference between 256 columns of + // a few blocks each and 256 columns of sixty + int[] floorTops = new int[256]; + int lowest = Integer.MAX_VALUE; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int top = floorTopAt(worldInfo, wc, floor, engine, (chunkX << 4) + x, (chunkZ << 4) + z); + floorTops[(x << 4) | z] = top; + lowest = Math.min(lowest, top); + } } - // Noised floor surface (plus island lift), then water up to sea level + Material deepBase = overworld ? Material.STONE : Material.NETHERRACK; + // Stop the bulk fill short of the shallowest column so every column + // still lays its own surface layers - a flat chunk (a market plaza, or + // dead level sea floor) would otherwise come out as bare base rock + int baseTop = Math.max(minHeight + 1, lowest - ROCK_BAND); + chunkData.setRegion(0, minHeight + 1, 0, 16, baseTop, 16, deepBase); + for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { - int worldX = (chunkX << 4) + x; - int worldZ = (chunkZ << 4) + z; - double noiseVal = gen.noise(worldX, worldZ, 0.5, 0.5, true); - int lift = terrainLift(worldInfo, worldX, worldZ); - int floorTop = wc.seaFloor() + (int) (NOISE_MAX + NOISE_MAX * noiseVal) + lift; - // Dock quay and market plaza terraforming (overworld only) - ColumnPlan plan = worldInfo.getEnvironment() == Environment.NORMAL - ? addon.getGalaxyEngine(worldInfo.getSeed()).columnPlanAt(worldX, worldZ).orElse(null) - : null; - if (plan != null) { - int wanted = plan.surfaceY() + 1; - floorTop = plan.blend() >= 1.0 ? wanted - : (int) Math.round(floorTop + (wanted - floorTop) * plan.blend()); - } - floorTop = Math.min(floorTop, worldInfo.getMaxHeight() - 1); - boolean land = floorTop > wc.seaHeight() + 1; - for (int y = wc.seaFloor(); y < floorTop; y++) { - chunkData.setBlock(x, y, z, columnMaterial(mats, y, floorTop, land, plan)); - } - // Water column above the floor - for (int y = Math.max(floorTop, wc.seaFloor()); y <= wc.seaHeight(); y++) { - chunkData.setBlock(x, y, z, wc.waterBlock()); - } + generateColumn(chunkData, worldInfo, wc, floor, engine, x, z, (chunkX << 4) + x, (chunkZ << 4) + z, + baseTop, floorTops[(x << 4) | z]); } } } /** - * Material for one block of a floor column. Underwater columns are the - * sea-floor palette; island columns that clear the sea get a soil profile - * (stone core, dirt subsoil, grass on top) so vanilla decoration can plant - * on them. Dock columns are a stone-brick quay with a plank deck; fully - * flattened plaza columns get a path surface. + * The Y of the topmost floor block in a column: the sea floor, plus the + * galaxy's island lift, overridden by any dock or plaza terraforming. */ - private Material columnMaterial(FloorMats mats, int y, int floorTop, boolean land, ColumnPlan plan) { + private int floorTopAt(WorldInfo worldInfo, WorldConfig wc, Seabed floor, GalaxyEngine engine, int worldX, + int worldZ) { + double shelfBlend = engine == null ? 0 : engine.shelfBlendAt(worldX, worldZ); + int floorTop = floor.heightAt(worldX, worldZ, shelfBlend) + terrainLift(worldInfo, worldX, worldZ); + ColumnPlan plan = engine == null ? null : engine.columnPlanAt(worldX, worldZ).orElse(null); + if (plan != null) { + int wanted = plan.surfaceY() + 1; + floorTop = plan.blend() >= 1.0 ? wanted : (int) Math.round(floorTop + (wanted - floorTop) * plan.blend()); + } + return Math.clamp(floorTop, worldInfo.getMinHeight() + 2, worldInfo.getMaxHeight() - 1); + } + + /** + * Lay down one column: floor material up to its top, then water to the sea + * surface. + */ + private void generateColumn(ChunkData chunkData, WorldInfo worldInfo, WorldConfig wc, Seabed floor, + GalaxyEngine engine, int x, int z, int worldX, int worldZ, int baseTop, int floorTop) { + ColumnPlan plan = engine == null ? null : engine.columnPlanAt(worldX, worldZ).orElse(null); + boolean land = floorTop > wc.seaHeight() + 1; + int depth = wc.seaHeight() - floorTop; + double sediment = floor.sedimentAt(worldX, worldZ); + SurfaceKind surface = land && engine != null ? engine.surfaceKindAt(worldX, worldZ) : SurfaceKind.GRASS; + + for (int y = baseTop; y < floorTop; y++) { + chunkData.setBlock(x, y, z, + columnMaterial(worldInfo, y, floorTop, land, depth, sediment, plan, surface)); + } + for (int y = Math.max(floorTop, baseTop); y <= wc.seaHeight(); y++) { + chunkData.setBlock(x, y, z, wc.waterBlock()); + } + } + + /** + * Material for one block of a floor column. + *

+ * Underwater, the floor reads its own depth: sunlit banks are sand, the + * middle depths are gravel and clay in broad patches, and the deep basins + * and rift floors are bare rock. Island columns that clear the sea get a + * soil profile so vanilla decoration can plant on them - grass normally, + * sand along an islet's shoreline, mycelium on a mushroom island. Dock + * columns are a stone-brick quay with a plank deck; fully flattened plaza + * columns get a path surface. + */ + private Material columnMaterial(WorldInfo worldInfo, int y, int floorTop, boolean land, int depth, + double sediment, ColumnPlan plan, SurfaceKind surface) { if (plan != null && plan.feature() == ColumnPlan.Feature.DOCK) { return y == floorTop - 1 ? IslandPalette.planks(plan.island().type()) : Material.STONE_BRICKS; } if (plan != null && plan.feature() == ColumnPlan.Feature.PLAZA && plan.blend() >= 1.0 && y == floorTop - 1) { return IslandPalette.plazaSurface(plan.island().type()); } - if (!land) { - return rand.nextBoolean() ? mats.top() : mats.base(); + if (worldInfo.getEnvironment() != Environment.NORMAL) { + return intersticeMaterial(y, floorTop, sediment); } - if (y == floorTop - 1) { - return Material.GRASS_BLOCK; + if (land) { + if (y == floorTop - 1) { + return IslandPalette.surface(surface); + } + if (y >= floorTop - SOIL_THICKNESS) { + return IslandPalette.subsoil(surface); + } + return Material.STONE; } - if (y >= floorTop - 4) { - return Material.DIRT; + // Sediment lies in a thin layer over rock; the deep basins and rift + // floors are scoured down to the rock itself + int fromTop = floorTop - 1 - y; + if (fromTop < SEDIMENT_THICKNESS && depth < ROCK_DEPTH) { + return sedimentMaterial(sediment, depth); + } + return fromTop < ROCK_BAND ? deepRock(sediment, depth) : Material.STONE; + } + + /** + * Sediment for the shallow and middle depths: banks of sand giving way to + * gravel and the odd clay pan, in patches rather than block-by-block noise. + */ + private static Material sedimentMaterial(double sediment, int depth) { + if (depth < 20) { + return sediment < 0.62 ? Material.SAND : Material.GRAVEL; + } + if (sediment < 0.34) { + return Material.SAND; + } + if (sediment < 0.78) { + return Material.GRAVEL; + } + return Material.CLAY; + } + + /** + * The rock under the sediment, and the floor of the deep basins and rifts. + */ + private static Material deepRock(double sediment, int depth) { + if (depth >= ROCK_DEPTH && sediment > 0.80) { + return Material.TUFF; + } + if (depth >= ROCK_DEPTH && sediment < 0.22) { + return Material.GRAVEL; } return Material.STONE; } + /** + * The interstice's floor: basalt and soul sand, nothing worth mining. + */ + private static Material intersticeMaterial(int y, int floorTop, double sediment) { + if (y >= floorTop - SEDIMENT_THICKNESS) { + return sediment < 0.5 ? Material.SOUL_SAND : Material.BASALT; + } + return Material.NETHERRACK; + } + @Override public boolean shouldGenerateNoise() { // No vanilla terrain: land only ever comes from the island masks @@ -196,6 +303,28 @@ public boolean shouldGenerateStructures() { return addon.getSettings().isMakeStructures(); } + /** + * Vanilla structures everywhere except on the trading islands themselves - + * a monument or a village dropped through a market plaza would wreck the + * one part of the world that is hand-built. + */ + @Override + public boolean shouldGenerateStructures(@NonNull WorldInfo worldInfo, @NonNull Random random, int chunkX, + int chunkZ) { + if (!addon.getSettings().isMakeStructures() || worldInfo.getEnvironment() != Environment.NORMAL) { + return false; + } + if (!addon.getSettings().isKeepStructuresOffIslands()) { + return true; + } + int centerX = (chunkX << 4) + 8; + int centerZ = (chunkZ << 4) + 8; + // A chunk's worth of margin so a structure anchored just outside cannot + // reach in + return addon.getGalaxyEngine(worldInfo.getSeed()) + .islandsNear(centerX, centerZ, addon.getSettings().getIslandTerrainRadius() + 16).isEmpty(); + } + @Override public List getDefaultPopulators(World world) { if (world.getEnvironment() != Environment.NORMAL) { diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java b/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java index 57b4dd3..cbf8915 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java @@ -7,6 +7,7 @@ import org.bukkit.entity.Villager; import world.bentobox.tradewinds.galaxy.IslandType; +import world.bentobox.tradewinds.galaxy.SurfaceKind; /** * The Bukkit-material face of each island type: dock wood, plaza surface, @@ -92,4 +93,30 @@ public static List professions(IslandType type) { public static List workstations(IslandType type) { return PALETTES.get(type).workstations(); } + + /** + * The top block of a land column. Vanilla decoration reads this to decide + * what will grow: grass gets trees and flowers, mycelium gets huge + * mushrooms, sand gets nothing much - which is what a beach should be. + * + * @param kind the surface kind + * @return the surface material + */ + public static Material surface(SurfaceKind kind) { + return switch (kind) { + case SAND -> Material.SAND; + case MYCELIUM -> Material.MYCELIUM; + case GRASS -> Material.GRASS_BLOCK; + }; + } + + /** + * What sits under that surface, before the column turns to stone. + * + * @param kind the surface kind + * @return the subsoil material + */ + public static Material subsoil(SurfaceKind kind) { + return kind == SurfaceKind.SAND ? Material.SANDSTONE : Material.DIRT; + } } diff --git a/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java b/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java index 8f81d9f..a17d680 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java +++ b/src/main/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProvider.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; import org.bukkit.NamespacedKey; import org.bukkit.Registry; @@ -89,7 +90,11 @@ public List getBiomes(WorldInfo worldInfo) { } }); } - GalaxyEngine.oceanBiomes().forEach(key -> { + // Every biome the galaxy can hand out must be declared here or the + // server refuses to use it: the deep-water variants (which is what + // decides where ocean monuments go), the islet land biomes, and the + // beaches around them + Stream.concat(GalaxyEngine.oceanBiomes().stream(), GalaxyEngine.isletBiomes().stream()).forEach(key -> { Biome b = resolve(key); if (!biomes.contains(b)) { biomes.add(b); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7c9fda5..b293a84 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -35,12 +35,18 @@ galaxy: spawn-island-type: FISHING # Chance (0-1) that a wild-islet grid cell hosts one - small unnamed islands, # unprotected: mine, farm, build, live. - wild-islet-chance: 0.3 + wild-islet-chance: 0.55 # Grid size in blocks for wild islets - much finer than the trading island grid, - # so the open sea is dotted with land you can put ashore on. - wild-islet-grid: 1200 - # Terrain radius of wild islets. 0 disables them. - wild-islet-radius: 70 + # so the open sea is dotted with land you can put ashore on: at 900 with chance + # 0.55 there is usually one within a few hundred blocks of anywhere. + wild-islet-grid: 900 + # Mean terrain radius of wild islets. 0 disables them. Each islet rolls its own + # size between roughly half and one and a half times this, so the sea holds + # everything from sandbars to proper little islands. + wild-islet-radius: 75 + # Chance (0-1) that a wild islet is a mushroom island: mycelium, mooshrooms and + # no hostile spawns. Rare enough to be worth the find. + mushroom-islet-chance: 0.06 # Relative spawn weight per island type. Higher = more common; 0 disables a type. type-weights: AGRICULTURAL: 10 @@ -283,12 +289,48 @@ world: vary-ocean-biomes: true # Water block for the overworld ocean. water-block: WATER - # Allow vanilla cave generation under the ocean floor. - make-caves: false - # Allow vanilla decoration (kelp, seagrass, trees on island land). + # Shape of the sea floor. The seabed is not scenery: its depth picks the ocean + # biome, and the biome is what tells vanilla where monuments, ruins and coral + # belong. Changing any of this needs a world reset to take effect everywhere. + seabed: + # False gives a featureless flat floor at the shelf depth; true gives shelves, + # basins, rifts and seamounts. + vary: true + # Depth below sea level of the shallowest open water - sunlit banks where + # coral, kelp and ocean ruins sit. + shelf-depth: 14 + # Depth below sea level of the deepest basins. Deep water gets the deep ocean + # biomes, which is what lets vanilla place ocean monuments. + abyss-depth: 46 + # Depth of the shelf every island and islet sits on. The natural floor blends + # toward this near land, so an island over an abyssal plain still stands in + # shallow water and still breaks the surface by the same amount. + island-shelf-depth: 18 + # Amplitude of the rolling hills on top of the basins. + relief: 9 + # How far below the surrounding floor a rift cuts at its deepest. 0 disables + # underwater canyons. + rift-depth: 26 + # How rare and narrow the rifts are (0-1). Higher means fewer, tighter canyons. + rift-threshold: 0.8 + # How far a seamount rises above the floor at its peak. They only grow on the + # deeper plains and never break the surface. 0 disables them. + seamount-height: 20 + # Allow vanilla cave generation: caves, ravines and caverns under the sea floor + # and inside the islands. Dry air pockets down there are a feature - somewhere + # to surface, and something to mine. + make-caves: true + # Allow vanilla decoration (kelp, seagrass, coral, trees on island land). make-decorations: true - # Allow vanilla structure generation (shipwrecks, ruins...). - make-structures: false + # Allow vanilla structure generation: shipwrecks, ocean ruins, ocean monuments, + # buried treasure and trial chambers. Which appear where is decided by the biomes + # the galaxy hands out - deep basins get monuments, warm shallows get warm ruins, + # islet beaches get treasure. + make-structures: true + # Keep vanilla structures off the trading islands themselves. A monument dropped + # through a market plaza would wreck the one part of the world that is hand-built. + # Wild islets are fair game either way. + keep-structures-off-islands: true max-islands: -1 default-game-mode: SURVIVAL nether: diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index 7b81c3d..b0b0dfb 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -248,9 +248,9 @@ void testNothingCrowdsTheSpawnIsland() { @Test void testWildIslets() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); - // With density 0 almost every cell is empty: some roll wild islets + // With density 0 almost every cell is empty: most roll wild islets int found = 0; - int[] sample = null; + Islet sample = null; for (int cx = -10; cx <= 10; cx++) { for (int cz = -10; cz <= 10; cz++) { if (engine.islandInCell(cx, cz).isEmpty() && engine.wildIsletInCell(cx, cz).isPresent()) { @@ -259,69 +259,137 @@ void testWildIslets() { } } } - // ~30% of 441 cells - assertTrue(found > 60 && found < 200, "Wild islet count off: " + found); + // ~55% of 441 cells + assertTrue(found > 180 && found < 320, "Wild islet count off: " + found); // Deterministic across engines GalaxyEngine again = new GalaxyEngine(config(SEED, 0.0)); - assertTrue(again.wildIsletAt(sample[0], sample[1]).isPresent()); + assertEquals(sample, again.isletAt(sample.centerX(), sample.centerZ()).orElseThrow()); // Terrain rises there, with a vanilla wild biome - assertEquals(45, engine.landLiftAt(sample[0], sample[1])); - assertTrue(engine.biomeKeyAt(sample[0], sample[1]).orElseThrow().startsWith("minecraft:")); + assertTrue(engine.landLiftAt(sample.centerX(), sample.centerZ()) > 20); + assertTrue(engine.biomeKeyAt(sample.centerX(), sample.centerZ()).orElseThrow().startsWith("minecraft:")); // Islets keep well clear of trading islands (terrain + islet + margin) GalaxyEngine dense = new GalaxyEngine(config(SEED, 1.0)); for (int cx = -20; cx <= 20; cx++) { for (int cz = -20; cz <= 20; cz++) { - java.util.Optional islet = dense.wildIsletInCell(cx, cz); + Optional islet = dense.wildIsletInCell(cx, cz); if (islet.isEmpty()) { continue; } - for (IslandSpec spec : dense.islandsNear(islet.get()[0], islet.get()[1], 2000)) { - double d = Math.sqrt(spec.distanceSquared(islet.get()[0], islet.get()[1])); - assertTrue(d >= 160 + 70, - "Islet at " + islet.get()[0] + "," + islet.get()[1] + " crowds " + spec.name()); + Islet i = islet.get(); + for (IslandSpec spec : dense.islandsNear(i.centerX(), i.centerZ(), 2000)) { + double d = Math.sqrt(spec.distanceSquared(i.centerX(), i.centerZ())); + assertTrue(d >= 160 + i.radius(), + "Islet at " + i.centerX() + "," + i.centerZ() + " crowds " + spec.name()); } } } } + @Test + void testIsletsVaryInSize() { + // Sandbars and proper little islands, not one stamped shape + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + int smallest = Integer.MAX_VALUE; + int largest = 0; + for (int cx = -12; cx <= 12; cx++) { + for (int cz = -12; cz <= 12; cz++) { + Optional islet = engine.wildIsletInCell(cx, cz); + if (islet.isPresent()) { + smallest = Math.min(smallest, islet.get().radius()); + largest = Math.max(largest, islet.get().radius()); + } + } + } + assertTrue(largest > smallest * 1.8, "Islets are all much the same size: " + smallest + ".." + largest); + } + + @Test + void testMushroomIsletsAreRareButReal() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + int islets = 0; + int mushroom = 0; + for (int cx = -25; cx <= 25; cx++) { + for (int cz = -25; cz <= 25; cz++) { + Optional islet = engine.wildIsletInCell(cx, cz); + if (islet.isPresent()) { + islets++; + if (islet.get().isMushroom()) { + mushroom++; + // A mushroom island is mycelium all over, with no beach + assertEquals(SurfaceKind.MYCELIUM, + engine.surfaceKindAt(islet.get().centerX(), islet.get().centerZ())); + assertEquals(GalaxyEngine.MUSHROOM_BIOME, + engine.biomeKeyAt(islet.get().centerX(), islet.get().centerZ()).orElseThrow()); + } + } + } + } + assertTrue(mushroom > 0, "No mushroom islets in " + islets + " islets"); + assertTrue(mushroom < islets * 0.2, "Mushroom islets are meant to be rare: " + mushroom + "/" + islets); + } + + @Test + void testIsletsHaveSandyShores() { + // The waterline is sand and beach biome - that is what lets vanilla + // wash up beached shipwrecks and bury treasure there + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + Islet islet = null; + for (int cx = 0; cx <= 20 && islet == null; cx++) { + for (int cz = 0; cz <= 20 && islet == null; cz++) { + islet = engine.wildIsletInCell(cx, cz).filter(i -> !i.isMushroom()).orElse(null); + } + } + assertTrue(islet != null, "No ordinary islet found"); + double shore = engine.isletShoreRadius(islet); + assertTrue(shore > 0 && shore < islet.radius(), "Shore radius out of range: " + shore); + // Inland is grass, the waterline is sand + assertEquals(SurfaceKind.GRASS, engine.surfaceKindAt(islet.centerX(), islet.centerZ())); + assertEquals(SurfaceKind.SAND, engine.surfaceKindAt(islet.centerX() + (int) shore, islet.centerZ())); + assertTrue(engine.biomeKeyAt(islet.centerX() + (int) shore, islet.centerZ()).orElseThrow().contains("beach")); + } + @Test void testIsletsAreFindable() { // The open sea must not be empty: an islet within a short row of - // anywhere (playtest: 6000x6000 blocks of nothing at 10000,10000) + // anywhere (playtest: 6000x6000 blocks of nothing at 10000,10000, and + // nothing found anywhere in 130 explored regions) GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.5)); - for (int[] point : new int[][] { { 10000, 10000 }, { -5000, 15000 }, { 30000, -20000 } }) { + for (int[] point : new int[][] { { 10000, 10000 }, { -5000, 15000 }, { 30000, -20000 }, + { -120000, 90000 } }) { double nearest = Double.MAX_VALUE; int grid = GalaxyConfig.DEFAULT_WILD_GRID; int cx = Math.floorDiv(point[0], grid); int cz = Math.floorDiv(point[1], grid); for (int i = cx - 4; i <= cx + 4; i++) { for (int j = cz - 4; j <= cz + 4; j++) { - java.util.Optional islet = engine.wildIsletInCell(i, j); + Optional islet = engine.wildIsletInCell(i, j); if (islet.isPresent()) { nearest = Math.min(nearest, - Math.hypot(islet.get()[0] - point[0], islet.get()[1] - point[1])); + Math.hypot(islet.get().centerX() - point[0], islet.get().centerZ() - point[1])); } } } - assertTrue(nearest < 2500, "No islet within 2500 blocks of " + point[0] + "," + point[1]); + assertTrue(nearest < 1200, "Nearest islet to " + point[0] + "," + point[1] + " is " + nearest); } } @Test void testOceanBiomesVaryButNeverJump() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.5)); - java.util.List order = GalaxyEngine.oceanBiomes(); + java.util.List shallow = GalaxyEngine.shallowOceanBiomes(); + java.util.List deep = GalaxyEngine.deepOceanBiomes(); java.util.Set seen = new java.util.HashSet<>(); int previous = -1; // Sail a long line, sampling every 50 blocks for (int x = -40_000; x <= 40_000; x += 50) { String key = engine.oceanBiomeKeyAt(x, 12_345); - int index = order.indexOf(key); - assertTrue(index >= 0, "Unknown ocean biome: " + key); + int index = engine.oceanTemperatureIndex(x, 12_345); + // Depth decides shallow or deep; temperature decides which of each + assertEquals(engine.isDeepWater(x, 12_345) ? deep.get(index) : shallow.get(index), key); seen.add(key); if (previous >= 0) { assertTrue(Math.abs(index - previous) <= 1, - "Ocean temperature jumped from " + order.get(previous) + " to " + key + " at x=" + x); + "Ocean temperature jumped by more than one step at x=" + x); } previous = index; } @@ -329,6 +397,105 @@ void testOceanBiomesVaryButNeverJump() { assertTrue(seen.size() >= 3, "Ocean is too uniform: only " + seen); } + @Test + void testDeepWaterExistsForMonuments() { + // Ocean monuments only generate in the deep ocean biomes, so if the sea + // never gets deep the whole vanilla structure set is unreachable + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + int deep = 0; + int total = 0; + for (int x = -30_000; x <= 30_000; x += 250) { + for (int z = -2_000; z <= 2_000; z += 250) { + if (engine.isletAt(x, z).isPresent()) { + continue; + } + total++; + if (engine.isDeepWater(x, z)) { + deep++; + assertTrue(engine.oceanBiomeKeyAt(x, z).startsWith("minecraft:deep_")); + } + } + } + // Deep basins are a real feature of the map, not a rounding error, and + // not so much of it that the shallows disappear + assertTrue(deep > total * 0.05, "Hardly any deep water: " + deep + "/" + total); + assertTrue(deep < total * 0.75, "Almost everything is deep water: " + deep + "/" + total); + } + + @Test + void testSeabedVariesAndNeverBreaksTheSurface() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + int shallowest = Integer.MIN_VALUE; + int deepest = Integer.MAX_VALUE; + for (int x = -20_000; x <= 20_000; x += 137) { + for (int z = -600; z <= 600; z += 137) { + int y = engine.seabedHeightAt(x, z); + // Open water everywhere: the galaxy's islands are the only land + assertTrue(y < 70, "Sea floor broke the surface at " + x + "," + z + " (y=" + y + ")"); + shallowest = Math.max(shallowest, y); + deepest = Math.min(deepest, y); + } + } + // Sunlit banks and dark basins, not one flat plain + assertTrue(shallowest - deepest > 30, + "Sea floor is too flat: y " + deepest + " to " + shallowest); + } + + @Test + void testRiftsCutDeepNarrowCanyons() { + Seabed seabed = new GalaxyEngine(config(SEED, 0.0)).getSeabed(); + int cut = 0; + int deepCut = 0; + int samples = 0; + for (int x = -20_000; x <= 20_000; x += 53) { + for (int z = -300; z <= 300; z += 53) { + samples++; + double rift = seabed.riftCut(x, z); + if (rift > 0) { + cut++; + } + if (rift > SeabedConfig.DEFAULT.riftDepth() * 0.5) { + deepCut++; + } + } + } + assertTrue(deepCut > 0, "No rift ever cuts deep"); + // Narrow: canyons are a feature of the floor, not most of it + assertTrue(cut < samples * 0.3, "Rifts are everywhere: " + cut + "/" + samples); + } + + @Test + void testIslandsAlwaysStandOnTheirOwnShelf() { + // An island that happens to fall over an abyssal plain must still break + // the surface by the same amount as one over a shelf + GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); + int shelf = SeabedConfig.DEFAULT.islandShelfDepth(); + for (int cx = -3; cx <= 3; cx++) { + for (int cz = -3; cz <= 3; cz++) { + IslandSpec spec = engine.islandInCell(cx, cz).orElse(null); + if (spec == null) { + continue; + } + assertEquals(1.0, engine.shelfBlendAt(spec.centerX(), spec.centerZ()), 1e-9); + assertEquals(70 - shelf, engine.seabedHeightAt(spec.centerX(), spec.centerZ())); + // ... and its shallows are shallows, whatever is underneath + assertFalse(engine.isDeepWater(spec.centerX(), spec.centerZ())); + } + } + } + + @Test + void testFlatSeabedConfigRestoresTheOldOcean() { + GalaxyConfig flat = new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 5, 5000, 70, + GalaxyConfig.defaultTypeWeights(), null, 0.0, 70, 1200, 0.0, SeabedConfig.flat(20)); + GalaxyEngine engine = new GalaxyEngine(flat); + for (int x = -5_000; x <= 5_000; x += 311) { + assertEquals(50, engine.seabedHeightAt(x, 700)); + // A floor with no basins has no deep water, so no monuments + assertFalse(engine.isDeepWater(x, 700)); + } + } + @Test void testOceanBiomesAreSeeded() { GalaxyEngine a = new GalaxyEngine(config(SEED, 0.5)); diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 98372a7..977ae7d 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -115,6 +115,20 @@ private RecordingChunkData generate(ChunkGeneratorWorld gen, Environment env, lo return recorder; } + /** + * The Y of the topmost solid floor block in a column, found by sounding + * down from the sea surface the way a lead line would. + */ + private int floorTop(RecordingChunkData r, int x, int z, int seaHeight) { + for (int y = seaHeight; y > -64; y--) { + Material m = r.get(x, y, z); + if (m != Material.WATER && m != Material.AIR) { + return y; + } + } + return -64; + } + @Test void testOceanShape() { // Far from the origin: the spawn island occupies 0,0 @@ -124,20 +138,42 @@ void testOceanShape() { for (int z = 0; z < 16; z++) { // Bedrock at the bottom assertEquals(Material.BEDROCK, r.get(x, -64, z)); - // Solid stone base below the sea floor + // Solid stone base under the whole floor assertEquals(Material.STONE, r.get(x, 0, z)); // Water at sea level assertEquals(Material.WATER, r.get(x, settings.getSeaHeight(), z)); // Air above sea level assertEquals(Material.AIR, r.get(x, settings.getSeaHeight() + 1, z)); - // Floor surface starts at or above the sea floor - Material atFloor = r.get(x, settings.getSeaFloor(), z); - assertTrue(atFloor == Material.SAND || atFloor == Material.SANDSTONE || atFloor == Material.WATER, + // The floor is sediment or rock, and it is under water + int top = floorTop(r, x, z, settings.getSeaHeight()); + assertTrue(top < settings.getSeaHeight(), "Open sea floor broke the surface at y=" + top); + Material atFloor = r.get(x, top, z); + assertTrue( + atFloor == Material.SAND || atFloor == Material.GRAVEL || atFloor == Material.CLAY + || atFloor == Material.STONE || atFloor == Material.TUFF, "Unexpected material at sea floor: " + atFloor); } } } + @Test + void testSeaFloorIsNotFlat() { + // The playtest complaint: "the sea floor is really barren and + // repetitive". Sound a long transect and check it actually moves. + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + int shallowest = Integer.MIN_VALUE; + int deepest = Integer.MAX_VALUE; + for (int chunk = 20; chunk < 160; chunk += 7) { + RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, chunk, 40); + for (int x = 0; x < 16; x += 4) { + int top = floorTop(r, x, 8, settings.getSeaHeight()); + shallowest = Math.max(shallowest, top); + deepest = Math.min(deepest, top); + } + } + assertTrue(shallowest - deepest > 25, "Sea floor barely varies: y " + deepest + " to " + shallowest); + } + @Test void testIntersticeShape() { ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); @@ -145,14 +181,14 @@ void testIntersticeShape() { for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { assertEquals(Material.BEDROCK, r.get(x, -64, z)); - // Netherrack base below the sea floor + // Netherrack base under the floor assertEquals(Material.NETHERRACK, r.get(x, 0, z)); // Water sea at interstice sea level assertEquals(Material.WATER, r.get(x, settings.getIntersticeSeaHeight(), z)); assertEquals(Material.AIR, r.get(x, settings.getIntersticeSeaHeight() + 1, z)); // Floor palette is nether-flavored - Material atFloor = r.get(x, settings.getIntersticeSeaFloor(), z); - assertTrue(atFloor == Material.SOUL_SAND || atFloor == Material.BASALT || atFloor == Material.WATER, + Material atFloor = r.get(x, floorTop(r, x, z, settings.getIntersticeSeaHeight()), z); + assertTrue(atFloor == Material.SOUL_SAND || atFloor == Material.BASALT, "Unexpected material at interstice floor: " + atFloor); } } @@ -164,8 +200,13 @@ void testDeterminism() { RecordingChunkData a = generate(new ChunkGeneratorWorld(addon), Environment.NORMAL, SEED, 3, -7); RecordingChunkData b = generate(new ChunkGeneratorWorld(addon), Environment.NORMAL, SEED, 3, -7); assertEquals(a.blocks, b.blocks); - // Different seed -> different floor - RecordingChunkData c = generate(new ChunkGeneratorWorld(addon), Environment.NORMAL, SEED + 1, 3, -7); + // A different galaxy seed -> a different sea floor. The seabed hangs off + // the galaxy seed, not the world seed, so that one number still decides + // the whole world (spec principle 5). + when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) + .thenReturn(new GalaxyEngine(new GalaxyConfig(SEED + 1, 2500, 160, 45, 0.0, 0, 5000, 70, + GalaxyConfig.defaultTypeWeights(), null))); + RecordingChunkData c = generate(new ChunkGeneratorWorld(addon), Environment.NORMAL, SEED, 3, -7); assertFalse(a.blocks.equals(c.blocks)); } @@ -263,9 +304,36 @@ void testShouldGenerateFlags() { // Vanilla terrain off: land only ever comes from island masks assertFalse(gen.shouldGenerateNoise()); assertFalse(gen.shouldGenerateSurface()); + // Everything else vanilla offers stays on - its carvers cut the caves + // under the sea floor and its structures furnish the sea assertTrue(gen.shouldGenerateMobs()); - assertFalse(gen.shouldGenerateCaves()); + assertTrue(gen.shouldGenerateCaves()); assertTrue(gen.shouldGenerateDecorations()); - assertFalse(gen.shouldGenerateStructures()); + assertTrue(gen.shouldGenerateStructures()); + } + + @Test + void testStructuresAreKeptOffTradingIslands() { + GalaxyEngine denseEngine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())).thenReturn(denseEngine); + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + IslandSpec spec = denseEngine.islandInCell(0, 0).orElseThrow(); + WorldInfo wi = worldInfo(Environment.NORMAL, SEED); + Random random = new Random(SEED); + + // No vanilla structure may generate on the island itself: a monument + // through the market plaza would wreck the hand-built part of the world + assertFalse(gen.shouldGenerateStructures(wi, random, spec.centerX() >> 4, spec.centerZ() >> 4)); + // ... but the open sea is fair game + assertTrue(gen.shouldGenerateStructures(wi, random, (spec.centerX() + 2000) >> 4, spec.centerZ() >> 4)); + // The interstice never gets any + assertFalse(gen.shouldGenerateStructures(worldInfo(Environment.NETHER, SEED), random, 100, 100)); + + // Turning the guard off lets vanilla place them anywhere + settings.setKeepStructuresOffIslands(false); + assertTrue(gen.shouldGenerateStructures(wi, random, spec.centerX() >> 4, spec.centerZ() >> 4)); + // And turning structures off means none at all + settings.setMakeStructures(false); + assertFalse(gen.shouldGenerateStructures(wi, random, 500, 500)); } } diff --git a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java index 4f86682..f238808 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/TradeWindsBiomeProviderTest.java @@ -67,6 +67,9 @@ void testOpenOceanVariesThroughOceanBiomes() { .map(key -> org.bukkit.Registry.BIOME.get(org.bukkit.NamespacedKey.fromString(key))) .collect(java.util.stream.Collectors.toSet()); for (int x = 100_000; x < 140_000; x += 500) { + if (emptyGalaxy.isletAt(x, 12_345).isPresent()) { + continue; // A wild islet is land, not sea - it has its own biome + } Biome biome = provider.getBiome(wi, x, settings.getSeaHeight(), 12_345); assertTrue(oceans.contains(biome), "Open sea should be an ocean biome, got " + biome.getKey()); // The same column above water reads the same sea From 2d7fd16ad6df1e137da5365e339e2ae05240686c Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 22:01:55 -0700 Subject: [PATCH 049/112] Seal the sea floor over vanilla's carvers Playtest screenshot: caves and ravines cut dry craters straight through the sea floor. Carvers have no idea there is an ocean overhead, and a generated chunk gets no block updates, so nothing ever flows in to fill what they cut. The API guarantees vanilla carvers run before generateCaves and that the ChunkData handed to it already contains their work, so that is where Poseidon repairs this. Poseidon fills every carved space under the sea; TradeWinds narrows it, because caves under the seabed are wanted. It recomputes the true floor top - a pure function, so it is exact - and puts back only a 5-block crust: air at or above the floor becomes water again, air just below becomes the sediment or rock the floor is made of, and anything deeper stays hollow. Break in from below and the cave floods, which is what a player expects. Cave mouths in an island flank above the waterline are left alone. 166 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 14 ++-- TRADEWINDS_SPEC.md | 2 +- docs/PROGRESS.md | 19 ++++++ .../generator/ChunkGeneratorWorld.java | 59 +++++++++++++++++ .../generator/ChunkGeneratorWorldTest.java | 64 +++++++++++++++++++ 5 files changed, 153 insertions(+), 5 deletions(-) diff --git a/TESTING.md b/TESTING.md index 2848f5b..e499cfc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -562,10 +562,16 @@ The test server's config has already been updated to the new defaults. (`world.keep-structures-off-islands: true`.) **Caves** (`make-caves: true`) -- [ ] Caves, ravines and caverns exist under the sea floor and inside the islands. -- [ ] Judgement call to make in play: cave mouths opening into the ocean leave dry air - pockets rather than flooding. That is intended (somewhere to surface, something - to mine) — but if it looks wrong, `world.make-caves: false` turns it off. +- [ ] **No voids in the sea floor.** Swim over a lot of open water: the floor is + unbroken. No dry craters, no open gashes, no ravines cut through to the water. + (Vanilla's carvers have no idea there is an ocean overhead and generated chunks + get no block updates, so nothing ever flows in to fill what they cut. The + generator seals a 5-block crust back over them in `generateCaves`, the Poseidon + fix — narrowed so the caves themselves survive.) +- [ ] Dig down through the sea floor anywhere promising: the caves are still there + underneath, and flood when you break into them. That is the point — sealed, not + filled in. +- [ ] Cave mouths in an island's flank *above* the waterline are left alone. - [ ] Caves do not break into a market plaza or dock from below. **Regression** diff --git a/TRADEWINDS_SPEC.md b/TRADEWINDS_SPEC.md index 93ea9b3..22f356f 100644 --- a/TRADEWINDS_SPEC.md +++ b/TRADEWINDS_SPEC.md @@ -62,7 +62,7 @@ A sea-trading game mode: an endless procedurally generated ocean dotted with **N - **Island shelf:** near any island or islet the natural floor eases to a standard shelf depth, so an island that falls over an abyssal plain still stands in shallow water and clears the waves by the same amount. This also makes each island's shoreline radius exact rather than noise-dependent. - Near an island center (from `GalaxyEngine` — O(1) neighbor-cell lookup per chunk): the shelf is lifted by a **radial falloff mask** `m(d)` (1 at center → 0 at the island's terrain radius). - **Biome:** `BiomeProvider` returns the island's biome within its terrain radius (per column); islets return their own biome with a sandy **beach ring** at the waterline; open sea returns an ocean biome chosen by seeded temperature *and depth* — deep water gets the `deep_*` variants. That depth-to-biome mapping is what tells vanilla where **ocean monuments** belong; temperature is what separates warm from cold **ocean ruins**. -- **Vanilla structures** (`world.make-structures`) supply shipwrecks, ocean ruins, monuments, buried treasure and trial chambers. They are suppressed per-chunk over trading islands (`world.keep-structures-off-islands`) so nothing drops through a hand-built plaza or dock; wild islets are fair game. **Vanilla caves** (`world.make-caves`) carve under the sea floor and inside the islands. +- **Vanilla structures** (`world.make-structures`) supply shipwrecks, ocean ruins, monuments, buried treasure and trial chambers. They are suppressed per-chunk over trading islands (`world.keep-structures-off-islands`) so nothing drops through a hand-built plaza or dock; wild islets are fair game. **Vanilla caves** (`world.make-caves`) carve under the sea floor and inside the islands; `generateCaves` then seals a crust back over anything they cut through the floor, so the sea floor is never left open (carvers know nothing about the ocean above, and generated chunks get no block updates to flood the holes). The caves survive under it. - Interstice variant: NETHER environment, its own shallow drab sea floor over basalt/soul sand, no galaxy, no structures. - **Island registration:** on first generation of an island's center chunk (or first entry — listener on chunk load), register an **unowned BentoBox island** at the center with range/protection from config and flags per security band. Island name announced on entry (BentoBox island enter event → action bar/title). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b03e387..431faf1 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -56,7 +56,26 @@ Islets also gained a sandy beach ring at the exact waterline (which is what lets vanilla beach shipwrecks and bury treasure) and a 6% chance of being **mushroom fields** — mycelium, mooshrooms, no hostile spawns. +**Sealing the carvers (same day, from a playtest screenshot).** Turning vanilla +caves on opened dry craters straight through the sea floor - carvers have no +idea there is an ocean overhead, and a generated chunk gets no block updates, +so nothing ever flows in to fill what they cut. Ben pointed at Poseidon, which +hit this and fixed it in `generateCaves`: the API guarantees vanilla's carvers +run *before* that hook and that the ChunkData handed to it already contains +their work, so it is the place to repair them. +Poseidon fills every carved space under the sea with rock. TradeWinds narrows +that, because caves under the seabed are wanted: it recomputes the true floor +top (pure function, so it is exact) and puts back only a 5-block **crust** - +air at or above the floor becomes water again, air just below becomes the same +sediment or rock the floor is made of, and anything deeper stays hollow. Caves +survive, with a sea floor over them; break in from below and they flood, which +is what a player expects. Cave mouths in an island's flank above the waterline +are left alone. + **Pitfalls:** +- The test helper reads the topmost *solid* block, which is `floorTop - 1` in + generator terms (blocks are written for `y < floorTop`). Worth remembering + when writing terrain assertions. - Stacked fbm octaves pull toward the middle: the first cut gave only 16 blocks of depth variation across a long transect — still barren. The basin field is now stretched about its midpoint and eased, restoring real shelves diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index a77b0cd..ee7090c 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -60,6 +60,11 @@ private record WorldConfig(int seaHeight, int seaFloor, Material waterBlock) { private static final int SEDIMENT_THICKNESS = 4; /** How far the varied rock reaches below the floor before it is all stone. */ private static final int ROCK_BAND = 8; + /** + * How thick a crust the sea floor keeps over vanilla's carvers. Thick enough + * that a cave roof is not one block of rock holding back the ocean. + */ + private static final int CRUST_THICKNESS = 5; /** Soil depth under a land surface before it turns to stone. */ private static final int SOIL_THICKNESS = 4; @@ -155,6 +160,60 @@ public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, } } + /** + * Seal the sea floor after vanilla's carvers have run. + *

+ * Carvers cut caves and ravines with no idea there is an ocean overhead, so + * left alone they open dry craters straight through the floor - generated + * chunks get no block updates, so nothing ever flows in to fill them. This + * is the Poseidon fix, narrowed: rather than filling every carved space + * under the sea, only the crust is put back. Anything deeper than + * {@link #CRUST_THICKNESS} below the floor stays hollow, so there are still + * caves down there to find - they just have a sea floor over them. + */ + @Override + public void generateCaves(@NonNull WorldInfo worldInfo, @NonNull Random random, int chunkX, int chunkZ, + @NonNull ChunkData chunkData) { + WorldConfig wc = seaConfig.get(worldInfo.getEnvironment()); + if (wc == null) { + return; + } + Seabed floor = seabed(worldInfo); + GalaxyEngine engine = worldInfo.getEnvironment() == Environment.NORMAL + ? addon.getGalaxyEngine(worldInfo.getSeed()) + : null; + int lowestY = worldInfo.getMinHeight() + 1; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int worldX = (chunkX << 4) + x; + int worldZ = (chunkZ << 4) + z; + int floorTop = floorTopAt(worldInfo, wc, floor, engine, worldX, worldZ); + int from = Math.max(lowestY, floorTop - CRUST_THICKNESS); + if (from > wc.seaHeight()) { + continue; // Land: a cave mouth in an island's flank is fine + } + double sediment = floor.sedimentAt(worldX, worldZ); + int depth = wc.seaHeight() - floorTop; + for (int y = from; y <= wc.seaHeight(); y++) { + if (chunkData.getType(x, y, z) != Material.AIR) { + continue; + } + // Above the floor is sea; below it is the crust it carved through + chunkData.setBlock(x, y, z, y >= floorTop ? wc.waterBlock() + : crustMaterial(worldInfo, y, floorTop, depth, sediment)); + } + } + } + } + + /** + * What to put back into a hole carved in the sea floor: the same sediment + * and rock the floor is made of, so the repair is invisible. + */ + private Material crustMaterial(WorldInfo worldInfo, int y, int floorTop, int depth, double sediment) { + return columnMaterial(worldInfo, y, floorTop, false, depth, sediment, null, SurfaceKind.GRASS); + } + /** * The Y of the topmost floor block in a column: the sea floor, plus the * galaxy's island lift, overridden by any dock or plaza terraforming. diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 977ae7d..9b45929 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -35,6 +35,8 @@ class ChunkGeneratorWorldTest extends CommonTestSetup { private static final long SEED = 12345L; + /** Mirrors ChunkGeneratorWorld.CRUST_THICKNESS. */ + private static final int CRUST = 5; private TradeWinds addon; private Settings settings; @@ -106,6 +108,10 @@ private ChunkData chunkData(RecordingChunkData recorder) { org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(Material.class)); + // getType, so the cave-sealing pass can read back what the carvers left + when(cd.getType(org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt())) + .thenAnswer(inv -> recorder.get(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2))); return cd; } @@ -298,6 +304,64 @@ void testSpawnIslandRisesAtOrigin() { assertTrue(landAboveSea, "The spawn island should rise above the sea at the origin"); } + @Test + void testCarvedSeaFloorIsSealedBackUp() { + // Playtest: vanilla's carvers cut dry craters straight through the sea + // floor, because a generated chunk gets no block updates and nothing + // ever flows in to fill them. + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + RecordingChunkData r = new RecordingChunkData(); + ChunkData cd = chunkData(r); + WorldInfo wi = worldInfo(Environment.NORMAL, SEED); + gen.generateNoise(wi, new Random(SEED), 40, 40, cd); + + // Carve a crater the way a cave or ravine would: a column of air from + // well under the floor up through the sea surface + int carvedX = 7; + int carvedZ = 9; + int solidTop = floorTop(r, carvedX, carvedZ, settings.getSeaHeight()); + for (int y = solidTop - 20; y <= settings.getSeaHeight(); y++) { + r.blocks.put(RecordingChunkData.key(carvedX, y, carvedZ), Material.AIR); + } + + gen.generateCaves(wi, new Random(SEED), 40, 40, cd); + + // The sea is back: no air anywhere below the surface in that column + for (int y = solidTop + 1; y <= settings.getSeaHeight(); y++) { + assertEquals(Material.WATER, r.get(carvedX, y, carvedZ), "Open water missing at y=" + y); + } + // ... standing on a crust of floor, not a one-block roof over the void + for (int y = solidTop - CRUST + 1; y <= solidTop; y++) { + assertTrue(r.get(carvedX, y, carvedZ) != Material.AIR, "Sea floor still open at y=" + y); + } + // ... and the cave underneath survives: this is not a blanket infill + assertEquals(Material.AIR, r.get(carvedX, solidTop - 20, carvedZ), + "The cave under the floor should still be there"); + } + + @Test + void testCaveMouthsInIslandFlanksAreLeftAlone() { + // Above the waterline a cave opening is just a cave opening + GalaxyEngine denseEngine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())).thenReturn(denseEngine); + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + IslandSpec spec = denseEngine.islandInCell(0, 0).orElseThrow(); + RecordingChunkData r = new RecordingChunkData(); + ChunkData cd = chunkData(r); + WorldInfo wi = worldInfo(Environment.NORMAL, SEED); + gen.generateNoise(wi, new Random(SEED), spec.centerX() >> 4, spec.centerZ() >> 4, cd); + + int x = spec.centerX() & 15; + int z = spec.centerZ() & 15; + int top = floorTop(r, x, z, 320); + assertTrue(top > settings.getSeaHeight(), "Expected island land at the center"); + int carved = top - 3; + r.blocks.put(RecordingChunkData.key(x, carved, z), Material.AIR); + + gen.generateCaves(wi, new Random(SEED), spec.centerX() >> 4, spec.centerZ() >> 4, cd); + assertEquals(Material.AIR, r.get(x, carved, z), "Dry land caves should be left alone"); + } + @Test void testShouldGenerateFlags() { ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); From 255c5e82b9e4b5968a225b3e11af7df58e255f50 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 22:30:28 -0700 Subject: [PATCH 050/112] Break the circle: ragged coastlines and hilly islands Playtest: "almost comically circular". Two causes, both mine. The land mask is a cosine of true distance from the center, which is a perfect disc by definition. The distance is now warped by a seeded noise field before the mask ever sees it, so the same mask draws bays and headlands for the cost of one noise lookup, scaled to the island's radius. Worse, the island shelf blend added earlier today levelled everything near land to a flat shelf - which is what made the shallow ring around an island a perfect circle too. Only the basin is now levelled toward the shelf, which is all the "islands always break the surface" guarantee needs; the rolling relief carries on across it at half strength, and rifts and seamounts fade out instead. Land also gained hilliness, a noise multiplier on the lift, so an island is hills and hollows rather than a smooth terraced dome. Everything that tests the island footprint moved onto the same warped distance - biome, shelf, icy approach ring - or the biome would draw a circle over ragged terrain. Beaches are no longer a geometric ring at an analytic shore radius, which only worked while the shelf was dead flat. isShoreAt asks the finished terrain whether it is a few blocks above sea level, so a beach follows the real waterline whatever shape the coast is. Verified by rendering islands as ASCII height maps: outlines are lobed and the spawn island's quay still runs out into open water. 168 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 8 + TRADEWINDS_SPEC.md | 3 +- docs/PROGRESS.md | 41 +++++ .../world/bentobox/tradewinds/Settings.java | 15 ++ .../world/bentobox/tradewinds/TradeWinds.java | 4 +- .../tradewinds/galaxy/GalaxyConfig.java | 22 ++- .../tradewinds/galaxy/GalaxyEngine.java | 171 ++++++++++++------ .../bentobox/tradewinds/galaxy/Seabed.java | 36 +++- .../tradewinds/galaxy/ShapeConfig.java | 39 ++++ src/main/resources/config.yml | 7 + .../tradewinds/galaxy/GalaxyEngineTest.java | 100 ++++++++-- .../generator/ChunkGeneratorWorldTest.java | 8 +- 12 files changed, 376 insertions(+), 78 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/ShapeConfig.java diff --git a/TESTING.md b/TESTING.md index e499cfc..d7dc6c3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -530,6 +530,14 @@ The test server's config has already been updated to the new defaults. nearest, worst case 1,376.) - [ ] Islets vary — sandbars barely clearing the water through to islands 140 blocks across, and small ones are lower as well as narrower. +- [ ] **Coastlines are not circles.** Islets and trading islands alike have bays, + headlands and lobes, and their land is hills and hollows rather than a smooth + dome with terraced contour rings. (`galaxy.coast-roughness` and + `galaxy.island-hilliness`; set both to 0 to see the old coin shape.) +- [ ] The shallow water around an island is not a perfectly circular pale ring — + the sea floor keeps rolling across the shelf. +- [ ] Islands are still whole: no island has broken up into a scatter of fragments, + and every trading island's quay still runs unbroken out into open water. - [ ] Every islet has a sandy shoreline ring with beach biome (snowy beach in cold seas), and grass or its own biome inland. - [ ] Roughly 1 islet in 16 is **mushroom fields**: mycelium, mooshrooms, red and diff --git a/TRADEWINDS_SPEC.md b/TRADEWINDS_SPEC.md index 22f356f..ca0c61b 100644 --- a/TRADEWINDS_SPEC.md +++ b/TRADEWINDS_SPEC.md @@ -60,7 +60,8 @@ A sea-trading game mode: an endless procedurally generated ocean dotted with **N - **The sea floor** (`galaxy.Seabed`, pure and unit-tested — it is not scenery, its depth picks the ocean biome): a broad **basin** field taking the floor from sunlit shelf (~14 blocks down) to abyssal plain (~46), **relief** rolling dunes over it, ridged-noise **rifts** cutting narrow canyons up to 26 blocks deeper, and **seamounts** rising off the deeper plains only. Seamounts are capped below the surface: the galaxy's islands stay the world's only land (principle 6). Floor material follows depth in patches — sand banks, gravel beds, clay pans, bare stone and tuff in the deeps. - **Island shelf:** near any island or islet the natural floor eases to a standard shelf depth, so an island that falls over an abyssal plain still stands in shallow water and clears the waves by the same amount. This also makes each island's shoreline radius exact rather than noise-dependent. -- Near an island center (from `GalaxyEngine` — O(1) neighbor-cell lookup per chunk): the shelf is lifted by a **radial falloff mask** `m(d)` (1 at center → 0 at the island's terrain radius). +- Near an island center (from `GalaxyEngine` — O(1) neighbor-cell lookup per chunk): the shelf is lifted by a **radial falloff mask** `m(d)` (1 at center → 0 at the island's terrain radius). The distance `d` fed to that mask is **warped by seeded noise** (`galaxy.coast-roughness`) so the mask draws bays and headlands rather than a disc, and the lift is modulated by a second field (`galaxy.island-hilliness`) so the land is hills and hollows rather than a dome. Everything that tests the island footprint — biome, shelf, icy approach ring — must use the same warped distance, or it draws a circle over ragged terrain. +- **Shores** are read from the finished terrain (land within a few blocks of sea level), never from a radius, so a beach follows the real waterline whatever shape the coast is. - **Biome:** `BiomeProvider` returns the island's biome within its terrain radius (per column); islets return their own biome with a sandy **beach ring** at the waterline; open sea returns an ocean biome chosen by seeded temperature *and depth* — deep water gets the `deep_*` variants. That depth-to-biome mapping is what tells vanilla where **ocean monuments** belong; temperature is what separates warm from cold **ocean ruins**. - **Vanilla structures** (`world.make-structures`) supply shipwrecks, ocean ruins, monuments, buried treasure and trial chambers. They are suppressed per-chunk over trading islands (`world.keep-structures-off-islands`) so nothing drops through a hand-built plaza or dock; wild islets are fair game. **Vanilla caves** (`world.make-caves`) carve under the sea floor and inside the islands; `generateCaves` then seals a crust back over anything they cut through the floor, so the sea floor is never left open (carvers know nothing about the ocean above, and generated chunks get no block updates to flood the holes). The caves survive under it. - Interstice variant: NETHER environment, its own shallow drab sea floor over basalt/soul sand, no galaxy, no structures. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 431faf1..ac914b2 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -56,6 +56,47 @@ Islets also gained a sandy beach ring at the exact waterline (which is what lets vanilla beach shipwrecks and bury treasure) and a 6% chance of being **mushroom fields** — mycelium, mooshrooms, no hostile spawns. +**Breaking the circle (same day, from a playtest screenshot).** Ben: "almost +comically circular". Two causes, both mine: +1. The land mask is a cosine of *true* distance from the center, which is a + perfect disc by definition. Fix: warp the distance with a seeded noise field + before the mask ever sees it (`shapedDistance`) - the same mask then draws + bays and headlands, for one noise lookup. The warp scales with the island's + radius, so a sandbar and a trading island are equally ragged for their size. +2. Worse, the island shelf blend I had just added levelled *everything* near + land to a flat shelf, which is what made the pale shallow ring a perfect + circle too. Now only the **basin** is levelled toward the shelf - that is + all the "islands always break the surface" guarantee needs. The rolling + relief carries on across the shelf at half strength; rifts and seamounts + fade out (a canyon through an anchorage helps nobody). + +Land also got `hilliness`: a noise multiplier on the lift, so an island is +hills and hollows rather than a smooth dome. Multiplied into the lift so it +fades at the shore instead of calving fragments off into the sea. + +Everything that reads the island footprint had to move onto the same warped +distance or the biome would have drawn a circle over the ragged terrain: +`islandAt`, `biomeKeyAt`, the icy approach ring, `shelfBlendAt`, `isletNear`. +Neighbour searches widened by `searchMargin()` to match, or a column in a +headland is missed. + +Beaches were rewritten while I was there. They were a geometric ring at an +analytically exact shore radius - which only worked while the shelf was dead +flat. Now `isShoreAt` just asks the finished terrain whether it is 1-4 blocks +above sea level, so the beach follows the real waterline however ragged the +coast, and needs no geometry at all. + +Verified by rendering islands as ASCII height maps before deploying: outlines +are lobed, the spawn island's quay still runs out to open water (pier end at +y=59, sea level 70). `galaxy.coast-roughness` / `galaxy.island-hilliness` +expose both, and `ShapeConfig.ROUND` restores the old coins. + +**Pitfall (twice now):** averaging fbm octaves pulls a field toward its middle. +The first coast warp used `Noise.fbm` and lost most of its range - the coast +came out nearly round anyway (reach 31-46 on a radius-73 islet). Two explicit +full-range `Noise.at` octaves instead. Same trap as the basin field earlier the +same day; worth remembering that fbm is for *shape*, not for amplitude. + **Sealing the carvers (same day, from a playtest screenshot).** Turning vanilla caves on opened dry craters straight through the sea floor - carvers have no idea there is an ocean overhead, and a generated chunk gets no block updates, diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 533a438..514a94a 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -125,6 +125,17 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.mushroom-islet-chance", needsReset = true) private double mushroomIsletChance = 0.06; + @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.") + @ConfigEntry(path = "galaxy.coast-roughness", needsReset = true) + private double coastRoughness = 0.25; + + @ConfigComment("How much the land height varies across an island, as a fraction of its full") + @ConfigComment("lift: hills and hollows instead of a smooth dome. 0 gives the dome.") + @ConfigEntry(path = "galaxy.island-hilliness", needsReset = true) + private double islandHilliness = 0.30; + @ConfigComment("Relative spawn weight per island type. Higher = more common; 0 disables a type.") @ConfigComment("Types: AGRICULTURAL, FOREST, FISHING, MINING, INDUSTRIAL, LUXURY, FROZEN.") @ConfigEntry(path = "galaxy.type-weights", needsReset = true) @@ -2476,6 +2487,10 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWildIsletGrid(int wildIsletGrid) { this.wildIsletGrid = wildIsletGrid; } public double getMushroomIsletChance() { return mushroomIsletChance; } public void setMushroomIsletChance(double mushroomIsletChance) { this.mushroomIsletChance = mushroomIsletChance; } + public double getCoastRoughness() { return coastRoughness; } + public void setCoastRoughness(double coastRoughness) { this.coastRoughness = coastRoughness; } + public double getIslandHilliness() { return islandHilliness; } + public void setIslandHilliness(double islandHilliness) { this.islandHilliness = islandHilliness; } public boolean isVarySeabed() { return varySeabed; } public void setVarySeabed(boolean varySeabed) { this.varySeabed = varySeabed; } public int getSeabedShelfDepth() { return seabedShelfDepth; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index f6487dd..26d1462 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -60,6 +60,7 @@ import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandType; import world.bentobox.tradewinds.galaxy.SeabedConfig; +import world.bentobox.tradewinds.galaxy.ShapeConfig; import world.bentobox.tradewinds.generator.ChunkGeneratorWorld; import world.bentobox.tradewinds.generator.GalaxyIslandRegistrar; import world.bentobox.tradewinds.generator.TradeWindsBiomeProvider; @@ -503,7 +504,8 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { s.getIslandTerrainRadius(), s.getLandLift(), s.getGalaxyDensity(), s.getStarterClusterMinIslands(), s.getBandRadius(), s.getSeaHeight(), typeWeights(), spawnIslandType(), s.getWildIsletChance(), s.getWildIsletRadius(), s.getWildIsletGrid(), - s.getMushroomIsletChance(), seabedConfig())); + s.getMushroomIsletChance(), seabedConfig(), + new ShapeConfig(s.getCoastRoughness(), s.getIslandHilliness()))); log("TradeWinds galaxy seed: " + seed); } return galaxyEngine; diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java index a4593ef..512ccb4 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyConfig.java @@ -32,13 +32,14 @@ * @param mushroomIsletChance chance (0-1) that an islet is a mushroom island - * rare enough to be worth the find * @param seabed shape of the ocean floor between the islands + * @param shape how ragged coastlines and island surfaces are * * @author tastybento */ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius, int wildIsletGrid, - double mushroomIsletChance, SeabedConfig seabed) { + double mushroomIsletChance, SeabedConfig seabed, ShapeConfig shape) { /** Default wild islet chance / radius / grid. */ public static final double DEFAULT_WILD_CHANCE = 0.55; @@ -54,17 +55,32 @@ public record GalaxyConfig(long seed, int minSeparation, int terrainRadius, int if (seabed == null) { seabed = SeabedConfig.DEFAULT; } + if (shape == null) { + shape = ShapeConfig.DEFAULT; + } + } + + /** + * Convenience constructor with the default island shape. + */ + public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, + int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, + IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius, int wildIsletGrid, + double mushroomIsletChance, SeabedConfig seabed) { + this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, + typeWeights, spawnIslandType, wildIsletChance, wildIsletRadius, wildIsletGrid, mushroomIsletChance, + seabed, ShapeConfig.DEFAULT); } /** - * Convenience constructor with the default seabed and mushroom chance. + * Convenience constructor with the default seabed, shape and mushroom chance. */ public GalaxyConfig(long seed, int minSeparation, int terrainRadius, int landLift, double density, int starterMinIslands, int bandRadius, int seaLevel, Map typeWeights, IslandType spawnIslandType, double wildIsletChance, int wildIsletRadius, int wildIsletGrid) { this(seed, minSeparation, terrainRadius, landLift, density, starterMinIslands, bandRadius, seaLevel, typeWeights, spawnIslandType, wildIsletChance, wildIsletRadius, wildIsletGrid, - DEFAULT_MUSHROOM_CHANCE, SeabedConfig.DEFAULT); + DEFAULT_MUSHROOM_CHANCE, SeabedConfig.DEFAULT, ShapeConfig.DEFAULT); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index 364fadf..5ffdfa5 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -47,6 +47,8 @@ public class GalaxyEngine { private static final long SALT_WILD_SIZE = 0x317DBEABL; private static final long SALT_WILD_MUSHROOM = 0x317DBEACL; private static final long SALT_OCEAN_TEMP = 0x0CEA17E1L; + private static final long SALT_COAST = 0x0C0A5701L; + private static final long SALT_HILLS = 0x81115001L; /** * Scale of the ocean's temperature regions, in blocks. Broad enough that a @@ -88,8 +90,12 @@ public class GalaxyEngine { /** Sandy fringes: where beached shipwrecks and buried treasure belong. */ private static final String BEACH_BIOME = "minecraft:beach"; private static final String SNOWY_BEACH_BIOME = "minecraft:snowy_beach"; - /** Half-width of an islet's beach ring, in blocks, either side of the shoreline. */ - private static final int BEACH_RING = 7; + /** + * How far above sea level land still counts as shore. Measured from the + * finished terrain rather than from a radius, so a beach follows the real + * waterline however ragged the coast is. + */ + private static final int SHORE_HEIGHT = 4; /** Smallest and largest islet radius as a fraction of the configured mean. */ private static final double ISLET_MIN_SCALE = 0.55; private static final double ISLET_MAX_SCALE = 1.55; @@ -290,9 +296,10 @@ public List islandsNear(int blockX, int blockZ, int radius) { * @return the island spec, or empty in open ocean */ public Optional islandAt(int blockX, int blockZ) { - long r2 = (long) config.terrainRadius() * config.terrainRadius(); - return islandsNear(blockX, blockZ, config.terrainRadius()).stream() - .filter(s -> s.distanceSquared(blockX, blockZ) <= r2).findFirst(); + int radius = config.terrainRadius(); + return islandsNear(blockX, blockZ, searchRadius(radius)).stream() + .filter(s -> shapedDistance(blockX, blockZ, s.centerX(), s.centerZ(), radius) <= radius) + .findFirst(); } /** @@ -308,11 +315,10 @@ public Optional islandAt(int blockX, int blockZ) { public int landLiftAt(int blockX, int blockZ) { int radius = config.terrainRadius(); double best = 0; - for (IslandSpec s : islandsNear(blockX, blockZ, radius)) { - double d = Math.sqrt(s.distanceSquared(blockX, blockZ)); + for (IslandSpec s : islandsNear(blockX, blockZ, searchRadius(radius))) { + double d = shapedDistance(blockX, blockZ, s.centerX(), s.centerZ(), radius); if (d < radius) { - double mask = 0.5 * (1 + Math.cos(Math.PI * d / radius)); - best = Math.max(best, mask); + best = Math.max(best, 0.5 * (1 + Math.cos(Math.PI * d / radius))); } } // Wild islets: free land between the trading islands. Small islets are @@ -320,11 +326,73 @@ public int landLiftAt(int blockX, int blockZ) { Optional wild = isletAt(blockX, blockZ); if (wild.isPresent()) { Islet islet = wild.get(); - double d = Math.sqrt(islet.distanceSquared(blockX, blockZ)); - double mask = 0.5 * (1 + Math.cos(Math.PI * d / islet.radius())); - return (int) Math.round(config.landLift() * mask * isletHeightScale(islet)); + double d = shapedDistance(blockX, blockZ, islet.centerX(), islet.centerZ(), islet.radius()); + double mask = 0.5 * (1 + Math.cos(Math.PI * Math.min(1.0, d / islet.radius()))); + return (int) Math.round(config.landLift() * mask * isletHeightScale(islet) * hillsAt(blockX, blockZ)); } - return (int) Math.round(config.landLift() * best); + return (int) Math.round(config.landLift() * best * hillsAt(blockX, blockZ)); + } + + /** + * Distance from a center, warped by a seeded noise field so that a radial + * mask draws a ragged coast instead of a circle. + *

+ * A cosine mask on true distance is a perfect disc - which is exactly what + * it looked like in play. Pushing the distance in and out with noise before + * the mask sees it turns the same mask into bays and headlands, and costs + * one noise lookup. The warp scales with the island's radius so a sandbar + * and a trading island are equally ragged for their size. + * + * @param blockX block x + * @param blockZ block z + * @param centerX island center x + * @param centerZ island center z + * @param radius the island's nominal radius + * @return the warped distance, never negative + */ + public double shapedDistance(int blockX, int blockZ, int centerX, int centerZ, int radius) { + double d = Math.hypot((double) blockX - centerX, (double) blockZ - centerZ); + if (config.shape().coastRoughness() <= 0) { + return d; + } + // Lattice tied to the radius: features big enough to be a bay, small + // enough that there are several around one island. Two explicit octaves + // rather than fbm - averaging octaves pulls the field toward its middle, + // which cost most of the warp and left the coast nearly round anyway. + int lattice = Math.max(16, radius / 2); + double broad = Noise.at(config.seed(), SALT_COAST, blockX, blockZ, lattice) * 2 - 1; + double detail = Noise.at(config.seed(), SALT_COAST + 1, blockX, blockZ, Math.max(8, lattice / 3)) * 2 - 1; + double warp = broad * 0.75 + detail * 0.25; + return Math.max(0, d + warp * radius * config.shape().coastRoughness()); + } + + /** + * How much the land rises or falls here relative to its plain radial cone: + * the difference between a smooth dome and something with hills and hollows + * on it. Multiplied into the lift, so it fades out at the shore rather than + * calving bits of land off into the sea. + * + * @param blockX block x + * @param blockZ block z + * @return multiplier around 1 + */ + private double hillsAt(int blockX, int blockZ) { + double hilliness = config.shape().hilliness(); + if (hilliness <= 0) { + return 1.0; + } + return 1.0 - hilliness / 2 + Noise.fbm(config.seed(), SALT_HILLS, blockX, blockZ, 44, 3, 0.5) * hilliness; + } + + /** + * How far to search for islands whose warped coastline could still reach a + * column. + * + * @param radius nominal radius + * @return the search radius in blocks + */ + private int searchRadius(int radius) { + return (int) Math.ceil(radius * config.shape().searchMargin()); } /** @@ -422,8 +490,9 @@ private Optional isletNear(int blockX, int blockZ, double scale) { for (int cz = cellZ - 1; cz <= cellZ + 1; cz++) { Optional islet = wildIsletInCell(cx, cz); if (islet.isPresent()) { - long reach = Math.round(islet.get().radius() * scale); - if (islet.get().distanceSquared(blockX, blockZ) <= reach * reach) { + Islet i = islet.get(); + double d = shapedDistance(blockX, blockZ, i.centerX(), i.centerZ(), i.radius()); + if (d <= i.radius() * scale) { return islet; } } @@ -433,25 +502,29 @@ private Optional isletNear(int blockX, int blockZ, double scale) { } /** - * The distance from an islet's center at which its land meets the water - - * exactly, because the shelf under an islet is flat by construction. The - * beach ring and the sand surface hang off this. + * The finished height of the land or sea floor at a column - the same + * number the chunk generator uses as the top of the column. * - * @param islet the islet - * @return shoreline radius in blocks, 0 if the islet never breaks the surface + * @param blockX block x + * @param blockZ block z + * @return the surface Y */ - public double isletShoreRadius(Islet islet) { - double lift = config.landLift() * isletHeightScale(islet); - if (lift <= 0) { - return 0; - } - // Land where lift * mask > shelf depth; invert the cosine mask for the - // exact crossing rather than sampling for it - double mask = config.seabed().islandShelfDepth() / lift; - if (mask >= 1.0) { - return 0; - } - return islet.radius() / Math.PI * Math.acos(Math.clamp(2 * mask - 1, -1.0, 1.0)); + public int surfaceHeightAt(int blockX, int blockZ) { + return seabedHeightAt(blockX, blockZ) + landLiftAt(blockX, blockZ); + } + + /** + * Whether a column is land just above the waterline - a shore. Read from + * the finished terrain rather than from a radius, so it follows a ragged + * coast exactly and needs no geometry of its own. + * + * @param blockX block x + * @param blockZ block z + * @return true on the beach + */ + public boolean isShoreAt(int blockX, int blockZ) { + int above = surfaceHeightAt(blockX, blockZ) - config.seaLevel(); + return above > 0 && above <= SHORE_HEIGHT; } /** @@ -588,13 +661,15 @@ public int seabedHeightAt(int blockX, int blockZ) { public double shelfBlendAt(int blockX, int blockZ) { double best = 0; int radius = config.terrainRadius(); - for (IslandSpec s : islandsNear(blockX, blockZ, radius * 2)) { - best = Math.max(best, shelfTaper(Math.sqrt(s.distanceSquared(blockX, blockZ)), radius)); + for (IslandSpec s : islandsNear(blockX, blockZ, searchRadius(radius * 2))) { + best = Math.max(best, + shelfTaper(shapedDistance(blockX, blockZ, s.centerX(), s.centerZ(), radius), radius)); } Optional islet = isletNear(blockX, blockZ, 2.0); if (islet.isPresent()) { + Islet i = islet.get(); best = Math.max(best, - shelfTaper(Math.sqrt(islet.get().distanceSquared(blockX, blockZ)), islet.get().radius())); + shelfTaper(shapedDistance(blockX, blockZ, i.centerX(), i.centerZ(), i.radius()), i.radius())); } return best; } @@ -629,9 +704,7 @@ public SurfaceKind surfaceKindAt(int blockX, int blockZ) { if (islet.get().isMushroom()) { return SurfaceKind.MYCELIUM; } - double shore = isletShoreRadius(islet.get()); - double d = Math.sqrt(islet.get().distanceSquared(blockX, blockZ)); - return shore > 0 && d >= shore - BEACH_RING ? SurfaceKind.SAND : SurfaceKind.GRASS; + return isShoreAt(blockX, blockZ) ? SurfaceKind.SAND : SurfaceKind.GRASS; } /** @@ -694,15 +767,15 @@ public Optional biomeKeyAt(int blockX, int blockZ) { return Optional.of(isletBiomeAt(wild.get(), blockX, blockZ)); } int radius = config.terrainRadius(); - long r2 = (long) radius * radius; - long ring2 = 4L * radius * radius; Optional ring = Optional.empty(); - for (IslandSpec s : islandsNear(blockX, blockZ, radius * 2)) { - long d2 = s.distanceSquared(blockX, blockZ); - if (d2 <= r2) { + for (IslandSpec s : islandsNear(blockX, blockZ, searchRadius(radius * 2))) { + // The same warped distance the terrain uses, so the biome follows + // the ragged coast instead of drawing a circle over it + double d = shapedDistance(blockX, blockZ, s.centerX(), s.centerZ(), radius); + if (d <= radius) { return Optional.of(s.biomeKey()); } - if (d2 <= ring2 && s.type().isIcyApproach()) { + if (d <= radius * 2.0 && s.type().isIcyApproach()) { ring = Optional.of("minecraft:frozen_ocean"); } } @@ -723,13 +796,9 @@ private String isletBiomeAt(Islet islet, int blockX, int blockZ) { if (islet.isMushroom()) { return islet.biomeKey(); } - double shore = isletShoreRadius(islet); - if (shore > 0) { - double d = Math.sqrt(islet.distanceSquared(blockX, blockZ)); - if (d >= shore - BEACH_RING && d <= shore + BEACH_RING) { - // Cold seas get a snowy shore, so the shoreline matches its water - return oceanTemperatureIndex(blockX, blockZ) <= 1 ? SNOWY_BEACH_BIOME : BEACH_BIOME; - } + if (isShoreAt(blockX, blockZ)) { + // Cold seas get a snowy shore, so the shoreline matches its water + return oceanTemperatureIndex(blockX, blockZ) <= 1 ? SNOWY_BEACH_BIOME : BEACH_BIOME; } return islet.biomeKey(); } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java b/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java index 83bb5b9..96ff4e0 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java @@ -73,12 +73,35 @@ public SeabedConfig getConfig() { * @return depth in blocks below sea level, at least {@link #MINIMUM_WATER} */ public double openSeaDepth(int blockX, int blockZ) { + return depthAt(blockX, blockZ, 0); + } + + /** + * Water depth at a column, given how much of an island's shelf it is on. + *

+ * Only the basin is levelled toward the island shelf - that is all + * the guarantee needs, and levelling everything is what made the shallows + * around an island a perfectly circular pale ring in play. The rolling + * relief carries on across the shelf (at half strength, so the guarantee + * still holds); rifts and seamounts fade out, since a canyon through an + * island's anchorage helps nobody. + * + * @param blockX block x + * @param blockZ block z + * @param shelfBlend 0 in open ocean, 1 on an island's own shelf + * @return depth in blocks below sea level + */ + private double depthAt(int blockX, int blockZ, double shelfBlend) { + double blend = Math.clamp(shelfBlend, 0, 1); double basin = basinAt(blockX, blockZ); double depth = config.shelfDepth() + basin * (config.abyssDepth() - config.shelfDepth()); + depth += (config.islandShelfDepth() - depth) * blend; // Rolling relief, centered so it neither raises nor lowers on average - depth += (Noise.fbm(seed, SALT_RELIEF, blockX, blockZ, RELIEF_LATTICE, 3, 0.5) - 0.5) * 2 * config.relief(); - depth += riftCut(blockX, blockZ); - depth -= seamountRise(blockX, blockZ, basin); + double relief = (Noise.fbm(seed, SALT_RELIEF, blockX, blockZ, RELIEF_LATTICE, 3, 0.5) - 0.5) * 2 + * config.relief(); + depth += relief * (1 - blend / 2); + depth += riftCut(blockX, blockZ) * (1 - blend); + depth -= seamountRise(blockX, blockZ, basin) * (1 - blend); // Open water everywhere: a shoal that reached the surface would be land // the galaxy never placed (spec principle 6) return Math.max(MINIMUM_WATER, depth); @@ -158,12 +181,7 @@ public double openSeaDepth(int blockX, int blockZ) { * @return the Y of the topmost floor block */ public int heightAt(int blockX, int blockZ, double shelfBlend) { - double depth = openSeaDepth(blockX, blockZ); - if (shelfBlend > 0) { - double blend = Math.clamp(shelfBlend, 0, 1); - depth = depth + (config.islandShelfDepth() - depth) * blend; - } - return seaLevel - (int) Math.round(depth); + return seaLevel - (int) Math.round(depthAt(blockX, blockZ, shelfBlend)); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/ShapeConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/ShapeConfig.java new file mode 100644 index 0000000..143f4b1 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/ShapeConfig.java @@ -0,0 +1,39 @@ +package world.bentobox.tradewinds.galaxy; + +/** + * How ragged the land is. A radial mask on its own draws a perfect circle - + * these two numbers are what stop an island looking like a coin. + * + * @param coastRoughness how far the coastline wanders in or out from the + * nominal radius, as a fraction of it. 0.25 means bays and headlands of + * up to a quarter of the island's radius; 0 gives a perfect circle + * @param hilliness how much the land height varies across an island, as a + * fraction of its full lift. 0 gives a smooth dome + * + * @author tastybento + */ +public record ShapeConfig(double coastRoughness, double hilliness) { + + /** Ragged enough to look like land, not so ragged it breaks up into islets. */ + public static final ShapeConfig DEFAULT = new ShapeConfig(0.25, 0.30); + + /** Perfect circles and smooth domes - the pre-fix shape. */ + public static final ShapeConfig ROUND = new ShapeConfig(0.0, 0.0); + + public ShapeConfig { + // Beyond about a third the coastline starts detaching into fragments + coastRoughness = Math.clamp(coastRoughness, 0.0, 0.35); + hilliness = Math.clamp(hilliness, 0.0, 0.6); + } + + /** + * How much wider than its nominal radius an island's footprint can reach + * once the coastline is warped outward. Neighbour searches have to look + * this bit further or a column in a headland is missed. + * + * @return search multiplier, at least 1 + */ + public double searchMargin() { + return 1.0 + coastRoughness + 0.05; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b293a84..45942f2 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -47,6 +47,13 @@ galaxy: # Chance (0-1) that a wild islet is a mushroom island: mycelium, mooshrooms and # no hostile spawns. Rare enough to be worth the find. mushroom-islet-chance: 0.06 + # 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. + coast-roughness: 0.25 + # How much the land height varies across an island, as a fraction of its full + # lift: hills and hollows instead of a smooth dome. 0 gives the dome. + island-hilliness: 0.3 # Relative spawn weight per island type. Higher = more common; 0 disables a type. type-weights: AGRICULTURAL: 10 diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index b0b0dfb..dd33782 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -108,13 +108,14 @@ void testBandsGetLawlessWithDistance() { void testLandLift() { GalaxyEngine engine = new GalaxyEngine(config(SEED, 1.0)); IslandSpec spec = engine.islandInCell(2, 3).orElseThrow(); - // Full lift at the center - assertEquals(45, engine.landLiftAt(spec.centerX(), spec.centerZ())); + // Near full lift at the center - hilliness varies it a little either way + int center = engine.landLiftAt(spec.centerX(), spec.centerZ()); + assertTrue(center > 36 && center <= 52, "Center lift off: " + center); // Tapered on the flank int flank = engine.landLiftAt(spec.centerX() + 80, spec.centerZ()); - assertTrue(flank > 0 && flank < 45, "Flank lift should taper: " + flank); - // Zero beyond the terrain radius - assertEquals(0, engine.landLiftAt(spec.centerX() + 161, spec.centerZ())); + assertTrue(flank > 0 && flank < center, "Flank lift should taper: " + flank); + // Zero well beyond the terrain radius, even allowing for a headland + assertEquals(0, engine.landLiftAt(spec.centerX() + 250, spec.centerZ())); // Zero in open ocean (empty cell far out with density check impossible at 1.0 -> use ocean point between islands) assertEquals(0, new GalaxyEngine(config(SEED, 0.0)).landLiftAt(1_000_000, 1_000_000)); } @@ -340,12 +341,81 @@ void testIsletsHaveSandyShores() { } } assertTrue(islet != null, "No ordinary islet found"); - double shore = engine.isletShoreRadius(islet); - assertTrue(shore > 0 && shore < islet.radius(), "Shore radius out of range: " + shore); - // Inland is grass, the waterline is sand + // Inland is grass, well above the water assertEquals(SurfaceKind.GRASS, engine.surfaceKindAt(islet.centerX(), islet.centerZ())); - assertEquals(SurfaceKind.SAND, engine.surfaceKindAt(islet.centerX() + (int) shore, islet.centerZ())); - assertTrue(engine.biomeKeyAt(islet.centerX() + (int) shore, islet.centerZ()).orElseThrow().contains("beach")); + assertTrue(engine.surfaceHeightAt(islet.centerX(), islet.centerZ()) > 70 + 4); + // Walking out to sea, the last land before the water is sand and beach + // biome - found by the real waterline, so it tracks a ragged coast + int sand = 0; + int beach = 0; + for (int d = 1; d < islet.radius() * 2; d++) { + int x = islet.centerX() + d; + if (!engine.isShoreAt(x, islet.centerZ())) { + continue; + } + if (engine.surfaceKindAt(x, islet.centerZ()) == SurfaceKind.SAND) { + sand++; + } + if (engine.biomeKeyAt(x, islet.centerZ()).orElseThrow().contains("beach")) { + beach++; + } + } + assertTrue(sand > 0, "No sandy shore on the islet"); + assertEquals(sand, beach, "Every shore column should carry a beach biome"); + } + + @Test + void testCoastlinesAreNotCircles() { + // Playtest: "almost comically circular". A cosine mask on true distance + // draws a perfect disc; the distance is warped before the mask sees it. + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + Islet islet = null; + for (int cx = 0; cx <= 20 && islet == null; cx++) { + for (int cz = 0; cz <= 20 && islet == null; cz++) { + islet = engine.wildIsletInCell(cx, cz).orElse(null); + } + } + assertTrue(islet != null, "No islet found"); + // Walk the compass, recording how far the land reaches on each bearing + List reach = new ArrayList<>(); + for (int deg = 0; deg < 360; deg += 5) { + double rad = Math.toRadians(deg); + int last = 0; + for (int d = 1; d < islet.radius() * 2; d++) { + int x = islet.centerX() + (int) Math.round(Math.cos(rad) * d); + int z = islet.centerZ() + (int) Math.round(Math.sin(rad) * d); + if (engine.surfaceHeightAt(x, z) > 70) { + last = d; + } + } + reach.add(last); + } + int min = reach.stream().mapToInt(Integer::intValue).min().orElseThrow(); + int max = reach.stream().mapToInt(Integer::intValue).max().orElseThrow(); + // Bays and headlands: the coast must be materially further out on some + // bearings than others + assertTrue(max - min > islet.radius() * 0.25, + "Coastline is near circular: reach " + min + " to " + max + " (r=" + islet.radius() + ")"); + // ... but it is still one island, not a scatter of fragments + assertTrue(min > 0, "The island broke up: some bearing has no land at all"); + } + + @Test + void testRoundShapeConfigRestoresPerfectCircles() { + // The knob that turns it all off again + GalaxyConfig round = new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 5, 5000, 70, + GalaxyConfig.defaultTypeWeights(), null, 0.0, 70, 1200, 0.0, SeabedConfig.flat(20), + ShapeConfig.ROUND); + GalaxyEngine engine = new GalaxyEngine(round); + IslandSpec spec = engine.islandInCell(1, 1).orElseThrow(); + int first = engine.landLiftAt(spec.centerX() + 100, spec.centerZ()); + // Same distance, every bearing, identical lift + for (int deg = 0; deg < 360; deg += 15) { + double rad = Math.toRadians(deg); + int x = spec.centerX() + (int) Math.round(Math.cos(rad) * 100); + int z = spec.centerZ() + (int) Math.round(Math.sin(rad) * 100); + assertEquals(first, engine.landLiftAt(x, z), 1.0); + } } @Test @@ -477,9 +547,17 @@ void testIslandsAlwaysStandOnTheirOwnShelf() { continue; } assertEquals(1.0, engine.shelfBlendAt(spec.centerX(), spec.centerZ()), 1e-9); - assertEquals(70 - shelf, engine.seabedHeightAt(spec.centerX(), spec.centerZ())); + // The basin is levelled to the island shelf; the rolling relief + // carries on across it at half strength, so this is a band, not + // a number - a dead flat shelf is what made islands look stamped + int bed = engine.seabedHeightAt(spec.centerX(), spec.centerZ()); + int slack = SeabedConfig.DEFAULT.relief() / 2 + 1; + assertTrue(Math.abs(bed - (70 - shelf)) <= slack, "Island shelf drifted to y" + bed); // ... and its shallows are shallows, whatever is underneath assertFalse(engine.isDeepWater(spec.centerX(), spec.centerZ())); + // ... so the island still clears the waves + assertTrue(engine.surfaceHeightAt(spec.centerX(), spec.centerZ()) > 70, + "Island " + spec.name() + " failed to break the surface"); } } } diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 9b45929..961eeb8 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -218,9 +218,13 @@ void testDeterminism() { @Test void testNoLandAboveSeaLevelAtScaleOne() { - // Stage 0: flat ocean everywhere - nothing may poke above the sea surface + // With no islands and no islets, nothing may poke above the sea surface: + // seamounts, shoals and relief all stay under water + when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) + .thenReturn(new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70, + GalaxyConfig.defaultTypeWeights(), null, 0.0, 0, 900))); ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); - RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 5, 5); + RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 200, 200); for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { for (int y = settings.getSeaHeight() + 1; y < 320; y++) { From d13eb623ddd10eb9f1f6ccd6f2781399a53c04d7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 22:54:31 -0700 Subject: [PATCH 051/112] Stage 6a: port flag policy inverted, reputation core, fines Opens the crime stage with the flag audit I owed. Three playtest bugs (no boat interaction, no chest boat, no workbench) had one root cause: BentoBox protection flags default to MEMBER rank and nobody is ever a member of an unowned island. Of 96 protection flags only 6 default to visitor rank, so 90 things were denied to everyone on every trading island and I had been allow-listing them one bug report at a time. PortFlags inverts it: a trading island is a public market, everything allowed at visitor rank except an explicit deny list. The list is the design statement - anti-grief, the island's own crops and stores and livestock (harvesting them would mint money outside trade margins, spec 5.0), and TRADING, so a right-click on a villager cannot bypass the market and the hold. It walks Flags.values() at runtime rather than naming an allow list, so a flag added by a future BentoBox is open at a port on day one. Reputation core: ReputationScale owns the number line, Standing owns what a band means to the world, ReputationService records crimes and decays them. Decay is credited only while online and in world, so time offline cannot launder a reputation. /tw fine returns a player to Clean and no further - money buys you out of Wanted, not into virtue - and a Fugitive is refused at SAFE ports, so buying back costs a trip into danger. CrimeListener carries the anti-bait guard: no innocent-kill penalty if the victim struck first, or dying at someone would be a weapon. 182 tests green. 6b (customs, contraband) and 6c (police, bounty payout) next. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 56 ++++ docs/PROGRESS.md | 54 ++++ .../world/bentobox/tradewinds/Settings.java | 136 ++++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 51 +++- .../api/events/TWReputationChangeEvent.java | 71 +++++ .../tradewinds/commands/TWFineCommand.java | 87 ++++++ .../bentobox/tradewinds/crime/Crime.java | 55 ++++ .../tradewinds/crime/CrimeListener.java | 152 +++++++++++ .../tradewinds/crime/ReputationScale.java | 67 +++++ .../tradewinds/crime/ReputationService.java | 250 ++++++++++++++++++ .../bentobox/tradewinds/crime/Standing.java | 62 +++++ .../tradewinds/dataobjects/TWPlayerData.java | 45 ++++ .../generator/GalaxyIslandRegistrar.java | 28 +- .../tradewinds/generator/PortFlags.java | 114 ++++++++ .../tradewinds/tasks/NavigationBarTask.java | 5 +- src/main/resources/addon.yml | 5 + src/main/resources/config.yml | 50 ++++ src/main/resources/locales/en-US.yml | 23 ++ .../tradewinds/crime/ReputationScaleTest.java | 118 +++++++++ .../tradewinds/generator/PortFlagsTest.java | 122 +++++++++ 20 files changed, 1522 insertions(+), 29 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/api/events/TWReputationChangeEvent.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWFineCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/Crime.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/CrimeListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/ReputationScale.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/ReputationService.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/Standing.java create mode 100644 src/main/java/world/bentobox/tradewinds/generator/PortFlags.java create mode 100644 src/test/java/world/bentobox/tradewinds/crime/ReputationScaleTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/generator/PortFlagsTest.java diff --git a/TESTING.md b/TESTING.md index d7dc6c3..6f89958 100644 --- a/TESTING.md +++ b/TESTING.md @@ -586,3 +586,59 @@ The test server's config has already been updated to the new defaults. - [ ] Trading islands still sit properly in the water, docks still run unbroken from plaza to pier end, and the spawn island is unchanged — an island over a deep basin must look the same as one over a shelf. + +## Stage 6a — The law: port flags and reputation + +**Flag policy changed for ALL trading islands.** Existing islands are re-flagged on +chunk load, but run `/twadmin reflag` after starting to be sure, then test as a +**non-op player** — every flag bug so far was invisible to an operator. + +### 6a-i — Port flags (the audit) +A trading island is now a public market: every protection flag is allowed at visitor +rank *except* an explicit deny list. +- [ ] **Things that must work** as a non-op visitor at any trading island: board and + leave a boat, open a chest boat and a cargo expander, use a crafting table, + anvil, furnace, grindstone, smithing table, loom, stonecutter, cartography + table and brewing stand, open doors/gates/trapdoors, press buttons, ring the + plaza bell, sleep in a bed, drop and pick up items, hit hostile mobs, fish, + throw an ender pearl. +- [ ] **Things that must NOT work**: break or place any block, use flint and steel, + prime TNT, empty a bucket, break an item frame or armour stand, edit a sign. +- [ ] **Things that must NOT work, because they would mint money outside the market** + (spec 5.0 — money enters the game only through trade margins): harvest or + trample the island's crops, open its barrels/containers/hoppers, take honey + from a hive, shear/milk/breed/kill its livestock. +- [ ] **Right-clicking a villager must NOT open a vanilla trade screen** — trading + goes through the market dialog and the hold only (principle 1). +- [ ] Hurting villagers is still refused on SAFE islands and allowed on rougher ones. +- [ ] `bands.port-denied-flags` / `port-allowed-flags` in config override both ways + (add `HARVEST` to allowed, reflag, and confirm you can now harvest). + +### 6a-ii — Reputation +- [ ] The navigation boss bar shows your standing, and it changes as you earn one. +- [ ] Punch a villager on a non-SAFE island: "The market saw that. Reputation -5." +- [ ] Kill a villager: bigger loss, and the message mentions money on your head. +- [ ] Kill an iron golem: reputation loss for killing police. +- [ ] Crossing a band boundary shows a title with your new standing. +- [ ] Commit enough crime to reach **Wanted** (−200 by default; one murder plus + change, or eight villager kills). Then **Fugitive** at −500. +- [ ] Reputation decays back toward zero by 1 point per 15 minutes of play — and + **only while online and in a TradeWinds world**. Log out for a while and + confirm your reputation is exactly where you left it. +- [ ] `/tw fine` out at sea is refused ("paid at a trading island"). At a port it + quotes a price, asks for confirmation, charges you, and returns you to Clean — + **never above it**. Check the balance actually moved. +- [ ] With nothing to answer for, `/tw fine` says so and charges nothing. +- [ ] As a Fugitive, `/tw fine` at a **SAFE** island is refused; at a rougher port it + works. (Crime pays, into danger — and buying your way back costs you the same.) +- [ ] `crime.enabled: false` stops all of it: no penalties, no messages, standing + reads Clean for everyone. + +### 6a-iii — PvP and bounties (partial; police land in 6c) +- [ ] Killing a player who is **not** a lawful target costs the killer 100 reputation + ("Murder"). +- [ ] But if the victim hit you first (within 30s), there is **no** penalty — the + anti-bait guard. Verify both ways round; a wanted player being able to farm + reputation loss off innocents by attacking them would be a weapon. +- [ ] Killing a **Wanted or Fugitive** player pays their bounty to the killer with no + penalty, and the bounty is cleared — kill them again and it pays **nothing**. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ac914b2..df6a877 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,60 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 6a — the law: port flags, reputation, fines (2026-07-31) + +**The flag audit, finally done properly.** Three separate playtest bugs (no boat +interaction, no chest boat, no workbench) had the same root cause: BentoBox +protection flags default to MEMBER rank, and **nobody is ever a member of an +unowned island**. Counting it out: of 96 protection flags only 6 default to +visitor rank, so 90 things were denied to everyone on every trading island, and +I had been allow-listing them one bug report at a time. + +So `PortFlags` inverts the policy. A trading island is a **public market**: +everything is allowed at visitor rank except an explicit deny list, and that +list is now the design statement, grouped by why - +1. anti-grief (the plaza and dock are the only hand-built terrain in the world), +2. **the two economies** - an island's crops, stores, hive and livestock are + denied because harvesting them would mint money outside trade margins + (spec 5.0), which no amount of playtesting would have surfaced as a *bug*, +3. `TRADING`, so a right-click on a villager cannot bypass the market and the + hold (principle 1) - the one I am most glad the audit caught, +4. livestock and residents, 5. portals. + +It walks `Flags.values()` at runtime rather than naming an allow list, so a flag +added by a future BentoBox is open at a port on day one instead of silently +locking something. Admins get `bands.port-denied-flags` / `port-allowed-flags` +either way. + +**Reputation core.** `ReputationScale` (pure, headless) owns the number line and +the bands; `Standing` owns what each band *means* to the world - hunted, lawful +target, barred from safe trade - so nothing downstream tests thresholds by hand. +`ReputationService` records crimes, fires `TWReputationChangeEvent`, and runs +decay. Decay is credited only while **online and in a TradeWinds world**, so a +week offline does not launder a reputation, and it walks toward zero from either +side without overshooting. + +Three speeds of recovery, per spec section 7: slow decay, `/tw fine` at a port, +and positive acts later. A fine returns you to **Clean and no further** - money +buys you out of Wanted, not into virtue - and a Fugitive is refused at SAFE +ports, so buying your way back costs you a trip into danger. Principle 4 in +reverse. + +`CrimeListener` carries the anti-bait guard: the innocent-kill penalty is +forfeit if the victim struck first within 30s, or standing near a wanted player +and dying at them would be a weapon. + +**Pitfalls:** +- I audited the flag list against BentoBox **HEAD**, but the compile target is + 3.18.1, which has a different flag set (no `FISHING`). Runtime iteration over + `Flags.values()` handles the drift; naming flags in code does not. Check the + jar, not the source, before naming a constant. +- `getPlayerStanding` had to take a `User` - a standing is player-facing text + and must be translated for the *viewer*, not the subject. +- 182 tests green. 6b (customs/contraband) and 6c (police/bounty payout, + nameplates) still to come; the bounty ledger already clears on payout so it + can only ever pay once. + ## The sea itself: seabed shape, vanilla structures, denser islets (2026-07-31) Ben, after covering 130 regions: "I have not found a single islet. Nothing... diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 514a94a..8ce5e69 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -20,6 +20,8 @@ import world.bentobox.bentobox.api.configuration.StoreAt; import world.bentobox.bentobox.api.configuration.WorldSettings; import world.bentobox.bentobox.api.flags.Flag; +import world.bentobox.tradewinds.crime.Crime; +import world.bentobox.tradewinds.crime.ReputationScale; import world.bentobox.bentobox.database.objects.adapters.Adapter; import world.bentobox.bentobox.database.objects.adapters.FlagBooleanSerializer; @@ -195,6 +197,112 @@ private static Map defaultBandHurtVillagers() { return map; } + @ConfigComment("Trading islands are public markets: EVERY protection flag is allowed at visitor") + @ConfigComment("rank except a built-in deny list (anti-grief, the island's own crops, stores and") + @ConfigComment("livestock, and direct villager trading - which would bypass the hold).") + @ConfigComment("BentoBox flags default to member rank and nobody is ever a member of an unowned") + @ConfigComment("island, so allow-listing instead meant finding each missing permission in play.") + @ConfigComment("List extra flag IDs here to deny them as well, e.g. [BED, ENDER_PEARL].") + @ConfigEntry(path = "bands.port-denied-flags") + private List portDeniedFlags = new ArrayList<>(); + + @ConfigComment("Flag IDs to force back to visitor rank, overriding the built-in deny list above.") + @ConfigComment("Use this to open up something the deny list closes, e.g. [HARVEST].") + @ConfigEntry(path = "bands.port-allowed-flags") + private List portAllowedFlags = new ArrayList<>(); + + /* CRIME AND REPUTATION */ + @ConfigComment("Master switch for the whole law layer: reputation, customs scans, police and") + @ConfigComment("bounties. False makes the world lawless in the sense of 'nothing is tracked'.") + @ConfigEntry(path = "crime.enabled") + private boolean crimeEnabled = true; + + @ConfigComment("The reputation number line. Positive is good; the bands hang off these.") + @ConfigEntry(path = "crime.reputation.floor") + private int reputationFloor = -1000; + @ConfigEntry(path = "crime.reputation.ceiling") + private int reputationCeiling = 1000; + @ConfigComment("At or above this score a player is UPSTANDING: better prices, fewer scans.") + @ConfigEntry(path = "crime.reputation.upstanding") + private int reputationUpstanding = 250; + @ConfigComment("Below this a player is an OFFENDER - and this is where a paid fine returns you.") + @ConfigEntry(path = "crime.reputation.offender") + private int reputationOffender = 0; + @ConfigComment("At or below this a player is WANTED: police respond, and killing them is lawful.") + @ConfigEntry(path = "crime.reputation.wanted") + private int reputationWanted = -200; + @ConfigComment("At or below this a player is a FUGITIVE: shot on sight, barred from safe trade.") + @ConfigEntry(path = "crime.reputation.fugitive") + private int reputationFugitive = -500; + + @ConfigComment("Minutes of online play per decay tick, and points moved toward zero each tick.") + @ConfigComment("Credited only while online and in a TradeWinds world, so logging out for a week") + @ConfigComment("does not launder a reputation. Crimes should shadow you for sessions.") + @ConfigEntry(path = "crime.reputation.decay-minutes") + private int reputationDecayMinutes = 15; + @ConfigEntry(path = "crime.reputation.decay-points") + private int reputationDecayPoints = 1; + + @ConfigComment("Currency charged per point of reputation debt when paying a fine. A fine can") + @ConfigComment("only ever take you back to Clean - money buys you out of Wanted, not into virtue.") + @ConfigEntry(path = "crime.fine-per-point") + private double finePerPoint = 2.0; + + @ConfigComment("Reputation lost per crime (negative numbers).") + @ConfigEntry(path = "crime.penalties") + private Map crimePenalties = defaultCrimePenalties(); + + @ConfigComment("Money added to the offender's bounty per crime. Paid once, to whoever kills") + @ConfigComment("them while they are a lawful target.") + @ConfigEntry(path = "crime.bounties") + private Map crimeBounties = defaultCrimeBounties(); + + private static Map defaultCrimePenalties() { + Map map = new HashMap<>(); + for (Crime crime : Crime.values()) { + map.put(crime.name(), crime.getDefaultPenalty()); + } + return map; + } + + private static Map defaultCrimeBounties() { + Map map = new HashMap<>(); + for (Crime crime : Crime.values()) { + map.put(crime.name(), (double) crime.getDefaultBounty()); + } + return map; + } + + /** + * The configured reputation number line. + * + * @return the scale + */ + public ReputationScale reputationScale() { + return new ReputationScale(reputationFloor, reputationCeiling, reputationUpstanding, reputationOffender, + reputationWanted, reputationFugitive); + } + + /** + * Reputation cost of a crime, from config. + * + * @param crime the crime + * @return points lost, negative + */ + public int penaltyFor(Crime crime) { + return crimePenalties.getOrDefault(crime.name(), crime.getDefaultPenalty()); + } + + /** + * Bounty added by a crime, from config. + * + * @param crime the crime + * @return money added to the bounty + */ + public double bountyFor(Crime crime) { + return crimeBounties.getOrDefault(crime.name(), (double) crime.getDefaultBounty()); + } + /* RESIDENTS */ @ConfigComment("Distance from the plaza center beyond which a resident is teleported home.") @ConfigEntry(path = "residents.tether-radius") @@ -2539,6 +2647,34 @@ public void setConcurrentIslands(int concurrentIslands) { public void setBandMonsterSpawn(Map bandMonsterSpawn) { this.bandMonsterSpawn = bandMonsterSpawn; } public Map getBandPvp() { return bandPvp; } public void setBandPvp(Map bandPvp) { this.bandPvp = bandPvp; } + public boolean isCrimeEnabled() { return crimeEnabled; } + public void setCrimeEnabled(boolean crimeEnabled) { this.crimeEnabled = crimeEnabled; } + public int getReputationFloor() { return reputationFloor; } + public void setReputationFloor(int v) { this.reputationFloor = v; } + public int getReputationCeiling() { return reputationCeiling; } + public void setReputationCeiling(int v) { this.reputationCeiling = v; } + public int getReputationUpstanding() { return reputationUpstanding; } + public void setReputationUpstanding(int v) { this.reputationUpstanding = v; } + public int getReputationOffender() { return reputationOffender; } + public void setReputationOffender(int v) { this.reputationOffender = v; } + public int getReputationWanted() { return reputationWanted; } + public void setReputationWanted(int v) { this.reputationWanted = v; } + public int getReputationFugitive() { return reputationFugitive; } + public void setReputationFugitive(int v) { this.reputationFugitive = v; } + public int getReputationDecayMinutes() { return reputationDecayMinutes; } + public void setReputationDecayMinutes(int v) { this.reputationDecayMinutes = v; } + public int getReputationDecayPoints() { return reputationDecayPoints; } + public void setReputationDecayPoints(int v) { this.reputationDecayPoints = v; } + public double getFinePerPoint() { return finePerPoint; } + public void setFinePerPoint(double finePerPoint) { this.finePerPoint = finePerPoint; } + public Map getCrimePenalties() { return crimePenalties; } + public void setCrimePenalties(Map m) { this.crimePenalties = m; } + public Map getCrimeBounties() { return crimeBounties; } + public void setCrimeBounties(Map m) { this.crimeBounties = m; } + public List getPortDeniedFlags() { return portDeniedFlags; } + public void setPortDeniedFlags(List portDeniedFlags) { this.portDeniedFlags = portDeniedFlags; } + public List getPortAllowedFlags() { return portAllowedFlags; } + public void setPortAllowedFlags(List portAllowedFlags) { this.portAllowedFlags = portAllowedFlags; } public Map getBandHurtVillagersRank() { return bandHurtVillagersRank; } public void setBandHurtVillagersRank(Map bandHurtVillagersRank) { this.bandHurtVillagersRank = bandHurtVillagersRank; } public int getResidentTetherRadius() { return residentTetherRadius; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 26d1462..2862a40 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.Objects; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.World.Environment; import org.bukkit.WorldCreator; @@ -28,11 +29,15 @@ import world.bentobox.tradewinds.commands.AdminReflagCommand; import world.bentobox.tradewinds.commands.AdminTpIslandCommand; import world.bentobox.tradewinds.commands.TWChartCommand; +import world.bentobox.tradewinds.commands.TWFineCommand; import world.bentobox.tradewinds.commands.TWRestartCommand; import world.bentobox.tradewinds.commands.TWSpawnCommand; import world.bentobox.tradewinds.commands.TWStarChartCommand; import world.bentobox.tradewinds.commands.TWTradeCommand; import world.bentobox.tradewinds.commands.TWWarpCommand; +import world.bentobox.tradewinds.crime.CrimeListener; +import world.bentobox.tradewinds.crime.ReputationService; +import world.bentobox.tradewinds.crime.Standing; import world.bentobox.tradewinds.dataobjects.IslandDataManager; import world.bentobox.tradewinds.dataobjects.PlayerDataManager; import world.bentobox.tradewinds.economy.MarketService; @@ -97,6 +102,9 @@ public class TradeWinds extends GameModeAddon { private StarChartService starChartService; private IntersticeService intersticeService; private @Nullable EncounterService encounterService; + private ReputationService reputationService; + private CrimeListener crimeListener; + private NamespacedKey policeKey; private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; @@ -155,6 +163,7 @@ public void setup() { new TWStarChartCommand(this); new TWTradeCommand(this); new TWRestartCommand(this); + new TWFineCommand(this); new IslandInfoCommand(this); new IslandSettingsCommand(this); new IslandLanguageCommand(this); @@ -248,6 +257,11 @@ public void onEnable() { encounterService = new EncounterService(this); encounterService.start(); registerListener(new EncounterListener(this)); + // The law: reputation, and the listeners that notice a crime + reputationService = new ReputationService(this); + reputationService.start(); + crimeListener = new CrimeListener(this); + registerListener(crimeListener); // Residents survive the night: no mob targeting, tether, respawn registerListener(new ResidentProtectionListener()); residentAuditTask = new ResidentAuditTask(this); @@ -274,6 +288,9 @@ public void onDisable() { if (encounterService != null) { encounterService.stop(); } + if (reputationService != null) { + reputationService.stop(); + } if (chartHolograms != null) { chartHolograms.clearAll(); } @@ -477,15 +494,39 @@ public EncounterService getEncounterService() { return encounterService; } + public ReputationService getReputationService() { + return reputationService; + } + + public CrimeListener getCrimeListener() { + return crimeListener; + } + + /** + * The PDC key marking an entity as a police unit: dispatched by the law, + * never drops loot, and killing one is a crime. + * + * @return the police tag key + */ + public NamespacedKey getPoliceKey() { + if (policeKey == null) { + policeKey = new NamespacedKey(getPlugin(), "police"); + } + return policeKey; + } + /** - * The player's legal standing, for HUDs. Until the reputation system - * (Stage 6) lands, everyone is Clean. + * The player's legal standing, for HUDs - translated for the viewer. * - * @param playerId the player + * @param user the viewer + * @param playerId the player whose standing is wanted * @return display name of the player's standing */ - public String getPlayerStanding(java.util.UUID playerId) { - return "Clean"; + public String getPlayerStanding(User user, java.util.UUID playerId) { + if (reputationService == null || !getSettings().isCrimeEnabled()) { + return user.getTranslation(Standing.CLEAN.getLocaleKey()); + } + return user.getTranslation(reputationService.standing(playerId).getLocaleKey()); } /** diff --git a/src/main/java/world/bentobox/tradewinds/api/events/TWReputationChangeEvent.java b/src/main/java/world/bentobox/tradewinds/api/events/TWReputationChangeEvent.java new file mode 100644 index 0000000..25285a4 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/api/events/TWReputationChangeEvent.java @@ -0,0 +1,71 @@ +package world.bentobox.tradewinds.api.events; + +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +import world.bentobox.tradewinds.crime.Crime; +import world.bentobox.tradewinds.crime.Standing; + +/** + * Fired when a player's reputation changes because of a crime. Not cancellable + * - the crime already happened; this is the notification other addons hook to + * (chat prefixes, scoreboards, logging). + * + * @author tastybento + */ +public class TWReputationChangeEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final Standing before; + private final Standing after; + private final int score; + private final Crime crime; + + public TWReputationChangeEvent(Player player, Standing before, Standing after, int score, Crime crime) { + this.player = player; + this.before = before; + this.after = after; + this.score = score; + this.crime = crime; + } + + public Player getPlayer() { + return player; + } + + public Standing getBefore() { + return before; + } + + public Standing getAfter() { + return after; + } + + public int getScore() { + return score; + } + + public Crime getCrime() { + return crime; + } + + /** + * @return true if this change moved the player between standings + */ + public boolean isBandChange() { + return before != after; + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWFineCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWFineCommand.java new file mode 100644 index 0000000..3b254d2 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWFineCommand.java @@ -0,0 +1,87 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; +import java.util.Optional; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.commands.ConfirmableCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.crime.Standing; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Pay off a criminal record at a civilized port. + *

+ * One of the three speeds of recovery (spec section 7), and the fastest - + * but money buys you out of Wanted, never into virtue: settling a fine returns + * you to Clean and no further. A Fugitive is refused outright at the safest + * islands; they have to find a rougher port to buy their way back, which is + * principle 4 (crime pays, into danger) working in reverse. + * + * @author tastybento + */ +public class TWFineCommand extends ConfirmableCommand { + + public TWFineCommand(CompositeCommand parent) { + super(parent, "fine"); + } + + @Override + public void setup() { + setPermission("island.fine"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.fine.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + if (!getWorld().equals(user.getWorld())) { + user.sendMessage("general.errors.wrong-world"); + return false; + } + double owed = addon.getReputationService().fine(user.getUniqueId()); + if (owed <= 0) { + user.sendMessage("tradewinds.crime.fine-none"); + return false; + } + Optional port = portHere(addon, user); + if (port.isEmpty()) { + user.sendMessage("tradewinds.crime.fine-no-market"); + return false; + } + // The safest ports will not take a fugitive's money + Standing standing = addon.getReputationService().standing(user.getUniqueId()); + if (standing.isBarredFromSafeTrade() && port.get().band() == SecurityBand.SAFE) { + user.sendMessage("tradewinds.crime.fine-barred"); + return false; + } + String amount = format(addon, owed); + user.sendMessage("tradewinds.crime.fine-quote", "[amount]", amount); + askConfirmation(user, () -> { + if (addon.getReputationService().payFine(user.getPlayer())) { + user.sendMessage("tradewinds.crime.fine-paid"); + } else { + user.sendMessage("tradewinds.crime.fine-cannot-afford", "[amount]", amount); + } + }); + return true; + } + + /** + * The trading island whose protection range the player is standing in. + */ + private Optional portHere(TradeWinds addon, User user) { + int x = user.getLocation().getBlockX(); + int z = user.getLocation().getBlockZ(); + int range = addon.getSettings().getIslandProtectionRange(); + return addon.getGalaxyEngine(getWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(s -> s.distanceSquared(x, z) <= (long) range * range).findFirst(); + } + + private String format(TradeWinds addon, double amount) { + return addon.getPlugin().getVault().map(v -> v.format(amount)).orElse(String.valueOf(amount)); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/Crime.java b/src/main/java/world/bentobox/tradewinds/crime/Crime.java new file mode 100644 index 0000000..b274f61 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/Crime.java @@ -0,0 +1,55 @@ +package world.bentobox.tradewinds.crime; + +/** + * The catalogue of crimes, each with its reputation cost and the bounty it adds + * to the offender's head. Costs are the spec section 7 proposals and every one + * is overridable in config (`crime.penalties.*`, `crime.bounties.*`). + * + * @author tastybento + */ +public enum Crime { + + /** Struck a resident villager. Cheap on its own; it adds up. */ + HURT_VILLAGER(-5, 0), + /** Killed a resident. The market recovers; your name does not. */ + KILL_VILLAGER(-25, 100), + /** Killed a police unit - golem, guardian or phantom. */ + KILL_POLICE(-15, 150), + /** Killed a player who was not a lawful target. */ + KILL_INNOCENT(-100, 500), + /** Caught by a customs scan with contraband aboard. */ + SMUGGLING(-30, 200), + /** Sold a villager as a passenger. */ + PASSENGER_TRADE(-20, 150); + + private final int defaultPenalty; + private final int defaultBounty; + + Crime(int defaultPenalty, int defaultBounty) { + this.defaultPenalty = defaultPenalty; + this.defaultBounty = defaultBounty; + } + + /** + * @return reputation points lost, negative + */ + public int getDefaultPenalty() { + return defaultPenalty; + } + + /** + * @return money added to the offender's bounty + */ + public int getDefaultBounty() { + return defaultBounty; + } + + /** + * The locale key announcing this crime to the offender. + * + * @return locale key + */ + public String getLocaleKey() { + return "tradewinds.crime." + name().toLowerCase(java.util.Locale.ENGLISH).replace('_', '-'); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/CrimeListener.java b/src/main/java/world/bentobox/tradewinds/crime/CrimeListener.java new file mode 100644 index 0000000..b9547d3 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/CrimeListener.java @@ -0,0 +1,152 @@ +package world.bentobox.tradewinds.crime; + +import org.bukkit.entity.IronGolem; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.entity.Villager; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.entity.PlayerDeathEvent; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.hooks.VaultHook; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Watches for crimes and reports them to the {@link ReputationService}. + *

+ * The anti-bait guard matters here (spec section 7): the innocent-kill penalty + * applies to direct kills only, and a victim who struck first forfeits it - + * otherwise standing next to a wanted player and dying at them is a weapon. + * + * @author tastybento + */ +public class CrimeListener implements Listener { + + /** How long a victim's own aggression exempts their killer, in millis. */ + private static final long PROVOCATION_WINDOW = 30_000L; + + private final TradeWinds addon; + /** Who hit whom last, for the anti-bait guard: "victim>attacker" to time. */ + private final java.util.Map provocations = new java.util.concurrent.ConcurrentHashMap<>(); + + public CrimeListener(TradeWinds addon) { + this.addon = addon; + } + + /** + * Striking a resident villager. The kill is handled on death, so this only + * charges for non-fatal blows. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDamage(EntityDamageByEntityEvent event) { + Player attacker = attacker(event); + if (attacker == null || !addon.inWorld(attacker.getWorld())) { + return; + } + if (event.getEntity() instanceof Player victim) { + // Remember that the victim's attacker was provoked, for the guard + provocations.put(key(attacker, victim), System.currentTimeMillis()); + return; + } + if (event.getEntity() instanceof Villager villager && villager.getHealth() > event.getFinalDamage()) { + addon.getReputationService().record(attacker, Crime.HURT_VILLAGER); + } + } + + /** + * Killing a resident villager, or a police unit. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityDeath(EntityDeathEvent event) { + Player killer = event.getEntity().getKiller(); + if (killer == null || !addon.inWorld(killer.getWorld())) { + return; + } + if (event.getEntity() instanceof Villager) { + addon.getReputationService().record(killer, Crime.KILL_VILLAGER); + } else if (event.getEntity() instanceof IronGolem || isPolice(event)) { + addon.getReputationService().record(killer, Crime.KILL_POLICE); + } + } + + /** + * Killing a player: lawful if they were a wanted target, a serious crime if + * they were not. Bounty payout lands in Stage 6c; the ledger is cleared + * here so it can only ever pay once. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerDeath(PlayerDeathEvent event) { + Player victim = event.getEntity(); + Player killer = victim.getKiller(); + if (killer == null || killer.equals(victim) || !addon.inWorld(victim.getWorld())) { + return; + } + Standing victimStanding = addon.getReputationService().standing(victim.getUniqueId()); + if (victimStanding.isLawfulTarget()) { + // Bounty hunting is lawful work: no penalty, and the bounty is paid + double bounty = addon.getReputationService().claimBounty(victim.getUniqueId()); + VaultHook vault = addon.getPlugin().getVault().orElse(null); + if (bounty > 0 && vault != null) { + vault.deposit(User.getInstance(killer), bounty); + User.getInstance(killer).sendMessage("tradewinds.crime.bounty-paid", + "[name]", victim.getName(), + "[amount]", vault.format(bounty)); + } + return; + } + if (wasProvokedBy(victim, killer)) { + // The victim struck first - this was a fight, not a murder + return; + } + addon.getReputationService().record(killer, Crime.KILL_INNOCENT); + } + + /** + * Whether the victim had recently attacked their killer, which forfeits the + * innocent-kill protection. + */ + private boolean wasProvokedBy(Player victim, Player killer) { + Long when = provocations.get(key(victim, killer)); + return when != null && System.currentTimeMillis() - when < PROVOCATION_WINDOW; + } + + private static String key(Player attacker, Player victim) { + return attacker.getUniqueId() + ">" + victim.getUniqueId(); + } + + /** + * The player behind a damage event, whether they swung or shot. + */ + static Player attacker(EntityDamageByEntityEvent event) { + if (event.getDamager() instanceof Player player) { + return player; + } + if (event.getDamager() instanceof Projectile projectile + && projectile.getShooter() instanceof Player shooter) { + return shooter; + } + return null; + } + + /** + * Whether a dead entity was a police unit. Police are PDC-tagged when they + * are dispatched (Stage 6c); until then only golems count. + */ + private boolean isPolice(EntityDeathEvent event) { + return event.getEntity().getPersistentDataContainer() + .has(addon.getPoliceKey(), org.bukkit.persistence.PersistentDataType.BYTE); + } + + /** + * Drop remembered provocations for a player who logged out. + * + * @param playerId the player + */ + public void forget(java.util.UUID playerId) { + provocations.keySet().removeIf(k -> k.contains(playerId.toString())); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/ReputationScale.java b/src/main/java/world/bentobox/tradewinds/crime/ReputationScale.java new file mode 100644 index 0000000..ff00f6b --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/ReputationScale.java @@ -0,0 +1,67 @@ +package world.bentobox.tradewinds.crime; + +/** + * The reputation number line: where the band boundaries sit, and how far the + * score can run either way. Pure arithmetic, no Bukkit - the consequences of a + * score are decided here and unit tested headlessly (spec principle 5). + * + * @param floor most negative reachable score + * @param ceiling most positive reachable score + * @param upstanding at or above this, a player is UPSTANDING + * @param offender below this, a player is an OFFENDER + * @param wanted at or below this, a player is WANTED + * @param fugitive at or below this, a player is a FUGITIVE + * + * @author tastybento + */ +public record ReputationScale(int floor, int ceiling, int upstanding, int offender, int wanted, int fugitive) { + + /** Spec section 7 proposals, adopted as defaults. */ + public static final ReputationScale DEFAULT = new ReputationScale(-1000, 1000, 250, 0, -200, -500); + + public ReputationScale { + // Thresholds must stay in order or a score could match two bands + upstanding = Math.max(offender + 1, upstanding); + wanted = Math.min(offender - 1, wanted); + fugitive = Math.min(wanted - 1, fugitive); + } + + /** + * The standing a score earns. + * + * @param score reputation score + * @return the band + */ + public Standing standingOf(int score) { + if (score <= fugitive) { + return Standing.FUGITIVE; + } + if (score <= wanted) { + return Standing.WANTED; + } + if (score < offender) { + return Standing.OFFENDER; + } + return score >= upstanding ? Standing.UPSTANDING : Standing.CLEAN; + } + + /** + * Clamp a score into the scale. + * + * @param score raw score + * @return score within [floor, ceiling] + */ + public int clamp(int score) { + return Math.clamp(score, floor, ceiling); + } + + /** + * How far a score is from being clean again - what a fine has to buy off. + * + * @param score reputation score + * @return points of debt, 0 if already clean or better + */ + public int debt(int score) { + return Math.max(0, offender - score); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/ReputationService.java b/src/main/java/world/bentobox/tradewinds/crime/ReputationService.java new file mode 100644 index 0000000..f337149 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/ReputationService.java @@ -0,0 +1,250 @@ +package world.bentobox.tradewinds.crime; + +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.hooks.VaultHook; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.TWReputationChangeEvent; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; + +/** + * The law's memory: one global reputation score per player, the standing it + * earns, and the three ways back from it. + *

+ * Recovery has three speeds by design (spec section 7): slow decay from clean + * play so a crime shadows you for sessions, fines that buy off the worst of it + * but never buy virtue (they stop at Clean), and positive acts above zero + * (post-MVP). Decay is credited per elapsed minute of online time, so + * logging out for a week does not launder a reputation. + * + * @author tastybento + */ +public class ReputationService { + + private final TradeWinds addon; + private BukkitTask decayTask; + + public ReputationService(TradeWinds addon) { + this.addon = addon; + } + + /** + * The configured number line. + * + * @return the reputation scale + */ + public ReputationScale scale() { + return addon.getSettings().reputationScale(); + } + + /** + * A player's raw reputation score. + * + * @param playerId the player + * @return the score + */ + public int score(UUID playerId) { + return addon.getPlayerDataManager().get(playerId).getReputation(); + } + + /** + * A player's standing. + * + * @param playerId the player + * @return the band + */ + public Standing standing(UUID playerId) { + return scale().standingOf(score(playerId)); + } + + /** + * The money on a player's head. + * + * @param playerId the player + * @return the bounty + */ + public double bounty(UUID playerId) { + return addon.getPlayerDataManager().get(playerId).getBounty(); + } + + /** + * Record a crime: dock reputation, add to the bounty, tell the player, and + * announce a band change if one happened. + * + * @param player the offender + * @param crime what they did + * @return the new standing + */ + public Standing record(Player player, Crime crime) { + if (!addon.getSettings().isCrimeEnabled()) { + return Standing.CLEAN; + } + int penalty = addon.getSettings().penaltyFor(crime); + double bounty = addon.getSettings().bountyFor(crime); + Standing before = standing(player.getUniqueId()); + TWPlayerData data = addon.getPlayerDataManager().get(player.getUniqueId()); + + int adjusted = scale().clamp(data.getReputation() + penalty); + if (adjusted == data.getReputation() && bounty <= 0) { + return before; + } + data.setReputation(adjusted); + data.setBounty(data.getBounty() + bounty); + addon.getPlayerDataManager().save(player.getUniqueId()); + + Standing after = scale().standingOf(adjusted); + User user = User.getInstance(player); + user.sendMessage(crime.getLocaleKey(), TextVariables.NUMBER, String.valueOf(Math.abs(penalty))); + if (after != before) { + announce(user, after); + } + Bukkit.getPluginManager() + .callEvent(new TWReputationChangeEvent(player, before, after, adjusted, crime)); + return after; + } + + /** + * Credit reputation back - clean play, or a delivery contract later. + * + * @param playerId the player + * @param points points to add, positive + * @return the new standing + */ + public Standing credit(UUID playerId, int points) { + TWPlayerData data = addon.getPlayerDataManager().get(playerId); + Standing before = scale().standingOf(data.getReputation()); + data.setReputation(scale().clamp(data.getReputation() + Math.max(0, points))); + Standing after = scale().standingOf(data.getReputation()); + if (after != before) { + Player player = Bukkit.getPlayer(playerId); + if (player != null) { + announce(User.getInstance(player), after); + } + } + return after; + } + + /** + * What it would cost to buy off a player's criminal record, or 0 if they + * have nothing to answer for. Money buys you out of Wanted, never into + * virtue: a fine can only ever take you back to Clean. + * + * @param playerId the player + * @return the fine, in currency + */ + public double fine(UUID playerId) { + int debt = scale().debt(score(playerId)); + if (debt <= 0) { + return 0; + } + return debt * addon.getSettings().getFinePerPoint(); + } + + /** + * Pay a player's fine in full: charge them and clear their record to Clean. + * The bounty is cleared with it - the law has been satisfied. + * + * @param player the player + * @return true if the fine was paid + */ + public boolean payFine(Player player) { + double owed = fine(player.getUniqueId()); + if (owed <= 0) { + return false; + } + VaultHook vault = addon.getPlugin().getVault().orElse(null); + User user = User.getInstance(player); + if (vault == null || !vault.has(user, owed)) { + return false; + } + vault.withdraw(user, owed); + TWPlayerData data = addon.getPlayerDataManager().get(player.getUniqueId()); + data.setReputation(scale().offender()); + data.setBounty(0); + addon.getPlayerDataManager().save(player.getUniqueId()); + announce(User.getInstance(player), Standing.CLEAN); + return true; + } + + /** + * Clear a bounty after it is paid out, so it pays exactly once. + * + * @param playerId the player who was killed + * @return the bounty that was cleared + */ + public double claimBounty(UUID playerId) { + TWPlayerData data = addon.getPlayerDataManager().get(playerId); + double owed = data.getBounty(); + data.setBounty(0); + addon.getPlayerDataManager().save(playerId); + return owed; + } + + private void announce(User user, Standing standing) { + user.sendMessage("tradewinds.standing.changed", "[standing]", + user.getTranslation(standing.getLocaleKey())); + } + + /** + * Start the clean-play decay: every online player drifts back toward zero. + */ + public void start() { + long period = Math.max(1, addon.getSettings().getReputationDecayMinutes()) * 60L * 20L; + decayTask = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::decayTick, period, period); + } + + public void stop() { + if (decayTask != null) { + decayTask.cancel(); + } + } + + /** + * One decay tick: every online player in a TradeWinds world moves one step + * toward zero, from whichever side. + */ + void decayTick() { + if (!addon.getSettings().isCrimeEnabled()) { + return; + } + int step = addon.getSettings().getReputationDecayPoints(); + for (Player player : Bukkit.getOnlinePlayers()) { + if (!addon.inWorld(player.getWorld())) { + continue; + } + TWPlayerData data = addon.getPlayerDataManager().get(player.getUniqueId()); + int moved = decayed(data.getReputation(), step); + if (moved != data.getReputation()) { + Standing before = scale().standingOf(data.getReputation()); + data.setReputation(moved); + Standing after = scale().standingOf(moved); + if (after != before) { + announce(User.getInstance(player), after); + } + } + } + } + + /** + * One step of decay toward zero, never overshooting it. Pure and testable. + * + * @param score current score + * @param step points per tick + * @return the decayed score + */ + static int decayed(int score, int step) { + if (score < 0) { + return Math.min(0, score + Math.abs(step)); + } + if (score > 0) { + return Math.max(0, score - Math.abs(step)); + } + return 0; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/Standing.java b/src/main/java/world/bentobox/tradewinds/crime/Standing.java new file mode 100644 index 0000000..471d6e8 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/Standing.java @@ -0,0 +1,62 @@ +package world.bentobox.tradewinds.crime; + +/** + * A player's legal standing, from a single global reputation score. + *

+ * The bands are what the world reacts to: prices, scan odds, whether police + * launch, and whether anyone may lawfully kill you. Thresholds are configurable + * ({@link ReputationScale}); the ordering is not. + * + * @author tastybento + */ +public enum Standing { + + /** Better than clean: cheaper goods, fewer scans. A status worth chasing. */ + UPSTANDING, + /** The default. Nobody is watching you. */ + CLEAN, + /** Known to customs: scanned more often, and it costs you at the till. */ + OFFENDER, + /** Police respond at civilized islands, and killing you is lawful work. */ + WANTED, + /** Shoot on sight, and no safe island will trade with you. */ + FUGITIVE; + + /** + * Whether police launch against this standing at a civilized island. + * + * @return true if the law is actively hunting + */ + public boolean isHunted() { + return this == WANTED || this == FUGITIVE; + } + + /** + * Whether anyone may kill this player without a reputation penalty - the + * bounty-hunting licence. + * + * @return true if lawfully killable + */ + public boolean isLawfulTarget() { + return isHunted(); + } + + /** + * Whether the safest islands refuse to trade at all (spec principle 4: + * crime pays, into danger). + * + * @return true if barred from safe-island markets + */ + public boolean isBarredFromSafeTrade() { + return this == FUGITIVE; + } + + /** + * The locale key for this standing's display name. + * + * @return locale key + */ + public String getLocaleKey() { + return "tradewinds.standing." + name().toLowerCase(java.util.Locale.ENGLISH); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index 7d9d7ee..8bcbce8 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -52,6 +52,27 @@ public class TWPlayerData implements DataObject { @Expose private long lastCharity; + /** + * Reputation score - one global number, positive is good. The standing + * bands derive from it (see ReputationScale). + */ + @Expose + private int reputation; + + /** + * Money on this player's head, paid once to whoever kills them while they + * are a lawful target. + */ + @Expose + private double bounty; + + /** + * Epoch millis when clean play last paid a decay tick, so time offline does + * not launder a reputation. + */ + @Expose + private long lastDecay; + public TWPlayerData() { // Required by the database } @@ -131,4 +152,28 @@ public Set getChartedIslands() { public void setChartedIslands(Set chartedIslands) { this.chartedIslands = chartedIslands; } + + public int getReputation() { + return reputation; + } + + public void setReputation(int reputation) { + this.reputation = reputation; + } + + public double getBounty() { + return bounty; + } + + public void setBounty(double bounty) { + this.bounty = bounty; + } + + public long getLastDecay() { + return lastDecay; + } + + public void setLastDecay(long lastDecay) { + this.lastDecay = lastDecay; + } } diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java index b37b8c8..10b0c42 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -115,28 +115,12 @@ public void applyBandFlags(Island island, IslandSpec spec) { addon.getSettings().getBandPvp().getOrDefault(band, spec.band().isPvp())); island.setSettingsFlag(Flags.MONSTER_NATURAL_SPAWN, addon.getSettings().getBandMonsterSpawn().getOrDefault(band, true)); - setRanks(island, java.util.Map.of( - Flags.HURT_VILLAGERS, addon.getSettings().getBandHurtVillagersRank().getOrDefault(band, 0), - // Everyone may drop and pick up items on trading islands - trade, - // jettisoned cargo, and plain convenience all depend on it - Flags.ITEM_DROP, 0, - Flags.ITEM_PICKUP, 0, - // Every trading island is a PORT: visitors must be able to - // moor, board and launch boats, defend themselves from - // whatever wandered in, and use a workbench. Without BOAT at - // visitor rank a sailor cannot get back into their own boat - // after shopping. - Flags.BOAT, 0, - Flags.HURT_MONSTERS, 0, - Flags.CRAFTING, 0, - // Doors and gates around the market stalls - Flags.DOOR, 0, - Flags.GATE, 0, - // Sailors must be able to open their OWN cargo at a port: a - // chest boat's inventory is guarded by CHEST, an expander by - // SHULKER_BOX - Flags.CHEST, 0, - Flags.SHULKER_BOX, 0)); + // A port is a public market: everything is allowed at visitor rank + // except an explicit deny list. See PortFlags - enumerating what IS + // allowed meant finding the gaps one bug report at a time. + setRanks(island, PortFlags.ranks(addon.getSettings().getBandHurtVillagersRank().getOrDefault(band, 0), + Set.copyOf(addon.getSettings().getPortDeniedFlags()), + Set.copyOf(addon.getSettings().getPortAllowedFlags()))); } /** diff --git a/src/main/java/world/bentobox/tradewinds/generator/PortFlags.java b/src/main/java/world/bentobox/tradewinds/generator/PortFlags.java new file mode 100644 index 0000000..4e8ac49 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/generator/PortFlags.java @@ -0,0 +1,114 @@ +package world.bentobox.tradewinds.generator; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import world.bentobox.bentobox.api.flags.Flag; +import world.bentobox.bentobox.lists.Flags; +import world.bentobox.bentobox.managers.RanksManager; + +/** + * What a visitor may do on a trading island. + *

+ * BentoBox protection flags default to MEMBER rank, and nobody is ever a + * member of an unowned island - so on a trading island every one of the 90 + * flags that takes the default is denied to every player, including the ones + * that make a port a port. That was invisible while testing as an operator and + * surfaced one bug report at a time: no boat interaction, no chest boat, no + * workbench, no doors. + *

+ * So the policy is inverted here. A trading island is a public market: + * everything is allowed at visitor rank except an explicit deny list, + * and the deny list is the design statement - the things that would wreck the + * one hand-built part of the world, or let a player earn money without trading + * for it. + * + * @author tastybento + */ +public final class PortFlags { + + private PortFlags() { + // Static use only + } + + /** + * Denied to everyone on a trading island. Grouped by why, because the "why" + * is the part worth keeping. + */ + private static final List DENIED = List.of( + // 1. Anti-grief: the plaza, dock, stalls and landmarks are the only + // hand-built terrain in the world + Flags.BREAK_BLOCKS, Flags.PLACE_BLOCKS, Flags.BREAK_SPAWNERS, Flags.BREAK_HOPPERS, + Flags.FLINT_AND_STEEL, Flags.TNT_PRIMING, Flags.BUCKET, Flags.COLLECT_LAVA, + Flags.ITEM_FRAME, Flags.ARMOR_STAND, Flags.SIGN_EDITING, Flags.SPAWN_EGGS, Flags.DRAGON_EGG, + + // 2. The two economies (spec 5.0): money enters the game ONLY through + // trade margins. Harvesting an island's crops, robbing its stores or + // shearing its livestock would all mint value out of nothing. + Flags.HARVEST, Flags.CROP_PLANTING, Flags.CROP_TRAMPLE, Flags.HIVE, + Flags.CONTAINER, Flags.BARREL, Flags.TRAPPED_CHEST, Flags.HOPPER, Flags.DROPPER, + Flags.DISPENSER, Flags.CRAFTER, Flags.LECTERN, Flags.BOOKSHELF, Flags.BEACON, + + // 3. Trading transacts through the market only (spec principle 1). + // Direct villager trades would bypass the hold entirely. + Flags.TRADING, + + // 4. The island's residents and livestock are not a larder + Flags.HURT_ANIMALS, Flags.HURT_TAMED_ANIMALS, Flags.SHEARING, Flags.MILKING, + Flags.BREEDING, Flags.LEASH, Flags.NAME_TAG, Flags.DYE, Flags.EGGS, Flags.TURTLE_EGGS, + + // 5. No portals out of the galaxy + Flags.NETHER_PORTAL, Flags.END_PORTAL); + + /** + * Flags whose rank the security band decides, so the blanket policy must + * not overwrite them. + */ + private static final Set BAND_CONTROLLED = Set.of(Flags.HURT_VILLAGERS); + + /** + * Every protection flag TradeWinds has an opinion about, mapped to the + * minimum rank required. Visitor rank (0) means "anyone"; member rank means + * nobody, on an island no one can join. + * + * @param hurtVillagersRank the band's rank for hurting villagers + * @param extraDenied flag IDs an admin has additionally denied + * @param extraAllowed flag IDs an admin has forced back to visitor rank + * @return flag to rank + */ + public static Map ranks(int hurtVillagersRank, Set extraDenied, + Set extraAllowed) { + Set denied = new HashSet<>(DENIED.stream().map(Flag::getID).toList()); + if (extraDenied != null) { + denied.addAll(extraDenied); + } + if (extraAllowed != null) { + denied.removeAll(extraAllowed); + } + Map ranks = new HashMap<>(); + for (Flag flag : Flags.values()) { + if (flag.getType() != Flag.Type.PROTECTION || BAND_CONTROLLED.contains(flag)) { + continue; + } + // CHANGE_SETTINGS stays with whoever owns the island - never a visitor + if (Flags.CHANGE_SETTINGS.equals(flag) || Flags.LOCK.equals(flag)) { + continue; + } + ranks.put(flag, denied.contains(flag.getID()) ? RanksManager.MEMBER_RANK : RanksManager.VISITOR_RANK); + } + ranks.put(Flags.HURT_VILLAGERS, hurtVillagersRank); + return ranks; + } + + /** + * The default deny list, for documentation and admin commands. + * + * @return flag IDs denied to visitors at a port + */ + public static List deniedIds() { + return DENIED.stream().map(Flag::getID).toList(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java index c900afe..3729406 100644 --- a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java +++ b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java @@ -104,9 +104,10 @@ record Reading(IslandSpec island, int dockDistance, float progress) { } private void show(Player player, Reading reading) { - Component name = User.getInstance(player).getTranslationAsComponent("tradewinds.hud.navigation", + User user = User.getInstance(player); + Component name = user.getTranslationAsComponent("tradewinds.hud.navigation", "[name]", reading.island().name(), - "[standing]", addon.getPlayerStanding(player.getUniqueId()), + "[standing]", addon.getPlayerStanding(user, player.getUniqueId()), "[distance]", String.valueOf(reading.dockDistance())); BossBar bar = bars.computeIfAbsent(player.getUniqueId(), id -> BossBar.bossBar(name, reading.progress(), color(reading.island().band()), diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 5f1bfae..3e3b731 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -36,6 +36,11 @@ permissions: tradewinds.island.restart: description: Allow restarting the trading career (config-capped) default: true + tradewinds.island.fine: + description: > + Allow settling a criminal record at a port. On by default - a player who + cannot pay off a reputation has no way back except waiting it out. + default: true tradewinds.island.info: description: Allow use of the island info command default: true diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 45942f2..112d0f1 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -133,6 +133,56 @@ bands: FRONTIER: 0 LAWLESS: 0 ANARCHIC: 0 + # Trading islands are public markets: EVERY protection flag is allowed at visitor + # rank except a built-in deny list (anti-grief; the island's own crops, stores and + # livestock; and direct villager trading, which would bypass the hold). + # BentoBox flags default to member rank and nobody is ever a member of an unowned + # island, so allow-listing instead meant finding each missing permission in play. + # Add flag IDs here to deny them too, e.g. [BED, ENDER_PEARL]. + port-denied-flags: [] + # Flag IDs forced back to visitor rank, overriding the deny list, e.g. [HARVEST]. + port-allowed-flags: [] +crime: + # Master switch for the whole law layer: reputation, customs scans, police and + # bounties. False makes the world lawless in the sense of 'nothing is tracked'. + enabled: true + reputation: + floor: -1000 + ceiling: 1000 + # At or above this a player is UPSTANDING: better prices, fewer scans. + upstanding: 250 + # Below this a player is an OFFENDER - and where a paid fine returns you. + offender: 0 + # At or below this a player is WANTED: police respond, killing them is lawful. + wanted: -200 + # At or below this a player is a FUGITIVE: shot on sight, barred from safe trade. + fugitive: -500 + # Minutes of ONLINE play per decay tick, and points moved toward zero each + # tick. Credited only while online and in a TradeWinds world, so logging out + # for a week does not launder a reputation. + decay-minutes: 15 + decay-points: 1 + # Currency charged per point of reputation debt when paying a fine (/tw fine). + # A fine can only ever take you back to Clean - money buys you out of Wanted, + # not into virtue. + fine-per-point: 2.0 + # Reputation lost per crime. + penalties: + HURT_VILLAGER: -5 + KILL_VILLAGER: -25 + KILL_POLICE: -15 + KILL_INNOCENT: -100 + SMUGGLING: -30 + PASSENGER_TRADE: -20 + # Money added to the offender's bounty per crime, paid once to whoever kills + # them while they are a lawful target. + bounties: + HURT_VILLAGER: 0.0 + KILL_VILLAGER: 100.0 + KILL_POLICE: 150.0 + KILL_INNOCENT: 500.0 + SMUGGLING: 200.0 + PASSENGER_TRADE: 150.0 residents: # Distance from the plaza center beyond which a resident is teleported home. tether-radius: 24 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index ef20ae0..3467a27 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -27,6 +27,8 @@ tradewinds: description: "open the island market (within its protection range)" restart: description: "restart your trading career (limited uses)" + fine: + description: "settle your criminal record at a port (returns you to Clean)" admin: help: description: "admin command for TradeWinds" @@ -47,6 +49,27 @@ tradewinds: teleported: "You set sail on the open ocean." starter-kit: given: "Your trading career begins: a boat, a trading pouch, and a little coal. Buy low, sell high." + standing: + upstanding: "Upstanding" + clean: "Clean" + offender: "Offender" + wanted: "Wanted" + fugitive: "Fugitive" + changed: "[title]Your standing:[subtitle][standing]" + crime: + hurt-villager: "The market saw that. Reputation -[number]." + kill-villager: "A trader is dead. Reputation -[number], and there is money on your head." + kill-police: "You killed a patrol. Reputation -[number]." + kill-innocent: "Murder. Reputation -[number], and a price on your head." + smuggling: "Caught with contraband. Reputation -[number]." + passenger-trade: "Word travels. Reputation -[number]." + bounty-paid: "Bounty collected on [name]: [amount]." + fine-none: "You have nothing to answer for." + fine-quote: "The harbourmaster totals your record: [amount] to settle it." + fine-paid: "Fine paid. Your record is clear - your name is another matter." + fine-cannot-afford: "You cannot afford the fine ([amount])." + fine-no-market: "Fines are paid at a trading island, not out here." + fine-barred: "'We don't take money from your sort.' Try a rougher port." status: clean: "Clean" hud: diff --git a/src/test/java/world/bentobox/tradewinds/crime/ReputationScaleTest.java b/src/test/java/world/bentobox/tradewinds/crime/ReputationScaleTest.java new file mode 100644 index 0000000..81b5b02 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/crime/ReputationScaleTest.java @@ -0,0 +1,118 @@ +package world.bentobox.tradewinds.crime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Headless tests of the reputation number line - no Bukkit, like the galaxy. + * + * @author tastybento + */ +class ReputationScaleTest { + + private final ReputationScale scale = ReputationScale.DEFAULT; + + @Test + void testBandsInOrderAcrossTheWholeScale() { + // Walking the scale from best to worst must pass through every band + // exactly once, in order - a score that matched two bands would make + // the world's reaction to a player undefined + Standing previous = null; + java.util.List seen = new java.util.ArrayList<>(); + for (int score = scale.ceiling(); score >= scale.floor(); score--) { + Standing standing = scale.standingOf(score); + if (standing != previous) { + assertFalse(seen.contains(standing), "Band " + standing + " reappeared at " + score); + seen.add(standing); + previous = standing; + } + } + assertEquals(java.util.List.of(Standing.UPSTANDING, Standing.CLEAN, Standing.OFFENDER, Standing.WANTED, + Standing.FUGITIVE), seen); + } + + @Test + void testDefaultThresholds() { + assertEquals(Standing.UPSTANDING, scale.standingOf(250)); + assertEquals(Standing.CLEAN, scale.standingOf(249)); + assertEquals(Standing.CLEAN, scale.standingOf(0)); + assertEquals(Standing.OFFENDER, scale.standingOf(-1)); + assertEquals(Standing.OFFENDER, scale.standingOf(-199)); + assertEquals(Standing.WANTED, scale.standingOf(-200)); + assertEquals(Standing.FUGITIVE, scale.standingOf(-500)); + assertEquals(Standing.FUGITIVE, scale.standingOf(-100_000)); + } + + @Test + void testOutOfOrderThresholdsAreRepaired() { + // An admin who types the thresholds in the wrong order must still get a + // usable scale, not a score that belongs to two bands + ReputationScale mangled = new ReputationScale(-1000, 1000, -50, 0, 400, 900); + assertTrue(mangled.upstanding() > mangled.offender()); + assertTrue(mangled.wanted() < mangled.offender()); + assertTrue(mangled.fugitive() < mangled.wanted()); + for (int score = 1000; score >= -1000; score -= 7) { + // Every score still resolves to exactly one band + assertTrue(mangled.standingOf(score) != null); + } + } + + @Test + void testConsequencesFollowTheBand() { + // The bands are only meaningful through what the world does about them + assertFalse(Standing.CLEAN.isHunted()); + assertFalse(Standing.OFFENDER.isHunted()); + assertTrue(Standing.WANTED.isHunted()); + assertTrue(Standing.FUGITIVE.isHunted()); + // Killing a wanted player is lawful work - no penalty for the killer + assertTrue(Standing.WANTED.isLawfulTarget()); + assertFalse(Standing.UPSTANDING.isLawfulTarget()); + // Only a fugitive is refused by the safe ports (spec principle 4) + assertTrue(Standing.FUGITIVE.isBarredFromSafeTrade()); + assertFalse(Standing.WANTED.isBarredFromSafeTrade()); + } + + @Test + void testDebtIsWhatAFineHasToBuy() { + assertEquals(0, scale.debt(0)); + assertEquals(0, scale.debt(500)); + assertEquals(200, scale.debt(-200)); + assertEquals(1000, scale.debt(-1000)); + } + + @Test + void testClampKeepsScoresOnTheScale() { + assertEquals(1000, scale.clamp(99_999)); + assertEquals(-1000, scale.clamp(-99_999)); + assertEquals(-17, scale.clamp(-17)); + } + + @Test + void testDecayAlwaysWalksTowardZeroAndStops() { + // From either side, and never overshooting: a decay that sailed past + // zero would turn a murderer upstanding by waiting + assertEquals(-95, ReputationService.decayed(-100, 5)); + assertEquals(0, ReputationService.decayed(-3, 5)); + assertEquals(95, ReputationService.decayed(100, 5)); + assertEquals(0, ReputationService.decayed(3, 5)); + assertEquals(0, ReputationService.decayed(0, 5)); + // A negative step is treated as its magnitude, not as a way to grow + assertEquals(-95, ReputationService.decayed(-100, -5)); + } + + @Test + void testEveryCrimeCostsSomethingAndTheWorstCostMost() { + for (Crime crime : Crime.values()) { + assertTrue(crime.getDefaultPenalty() < 0, crime + " should cost reputation"); + assertTrue(crime.getDefaultBounty() >= 0, crime + " should not pay the offender"); + } + assertTrue(Crime.KILL_INNOCENT.getDefaultPenalty() < Crime.KILL_VILLAGER.getDefaultPenalty()); + assertTrue(Crime.KILL_VILLAGER.getDefaultPenalty() < Crime.HURT_VILLAGER.getDefaultPenalty()); + // One murder must be enough to make you Wanted - the headline crime + // cannot leave you merely an Offender + assertEquals(Standing.WANTED, scale.standingOf(Crime.KILL_INNOCENT.getDefaultPenalty() * 2)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/PortFlagsTest.java b/src/test/java/world/bentobox/tradewinds/generator/PortFlagsTest.java new file mode 100644 index 0000000..50b3117 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/generator/PortFlagsTest.java @@ -0,0 +1,122 @@ +package world.bentobox.tradewinds.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.api.flags.Flag; +import world.bentobox.bentobox.lists.Flags; +import world.bentobox.bentobox.managers.RanksManager; +import world.bentobox.tradewinds.CommonTestSetup; + +/** + * The port flag policy: a trading island is a public market, so everything is + * allowed at visitor rank except an explicit deny list. + *

+ * This is the test that should have existed from Stage 2. BentoBox protection + * flags default to MEMBER rank and nobody is ever a member of an unowned + * island, so allow-listing meant finding each missing permission one playtest + * bug at a time: no boat interaction, no chest boat, no workbench, no doors. + * + * @author tastybento + */ +class PortFlagsTest extends CommonTestSetup { + + private Map ranks() { + return PortFlags.ranks(RanksManager.MEMBER_RANK, Set.of(), Set.of()); + } + + @Test + void testEveryProtectionFlagHasARulingExceptTheOwnersOwn() { + Map ranks = ranks(); + for (Flag flag : Flags.values()) { + if (flag.getType() != Flag.Type.PROTECTION) { + continue; + } + if (Flags.CHANGE_SETTINGS.equals(flag) || Flags.LOCK.equals(flag)) { + // Deliberately untouched: these belong to whoever owns the island + assertFalse(ranks.containsKey(flag), flag.getID() + " must stay the owner's"); + continue; + } + assertTrue(ranks.containsKey(flag), + "No ruling for " + flag.getID() + " - a flag left at its default is denied to everyone"); + } + } + + @Test + void testAPortWorksAsAPort() { + Map ranks = ranks(); + // Every one of these was a playtest bug report before the policy was + // inverted. They are the difference between a port and a locked gate. + for (Flag flag : new Flag[] { Flags.BOAT, Flags.CHEST, Flags.SHULKER_BOX, Flags.MOUNT_INVENTORY, + Flags.CRAFTING, Flags.DOOR, Flags.GATE, Flags.TRAPDOOR, Flags.ITEM_DROP, Flags.ITEM_PICKUP, + Flags.HURT_MONSTERS, Flags.RIDING, Flags.ANVIL, Flags.FURNACE, Flags.BED, + Flags.BELL_RINGING, Flags.ENDER_PEARL }) { + assertEquals(RanksManager.VISITOR_RANK, ranks.get(flag), + flag.getID() + " must be allowed at a port"); + } + } + + @Test + void testTheMarketCannotBeGriefedOrRobbed() { + Map ranks = ranks(); + // Anti-grief: the plaza and dock are the only hand-built terrain there is + for (Flag flag : new Flag[] { Flags.BREAK_BLOCKS, Flags.PLACE_BLOCKS, Flags.FLINT_AND_STEEL, + Flags.TNT_PRIMING, Flags.BUCKET, Flags.ITEM_FRAME, Flags.ARMOR_STAND }) { + assertTrue(ranks.get(flag) > RanksManager.VISITOR_RANK, flag.getID() + " must be denied at a port"); + } + // The two economies: money enters the game ONLY through trade margins, + // so an island's crops, stores and livestock are not a free income + for (Flag flag : new Flag[] { Flags.HARVEST, Flags.CONTAINER, Flags.BARREL, Flags.HOPPER, + Flags.SHEARING, Flags.MILKING, Flags.HURT_ANIMALS, Flags.HIVE }) { + assertTrue(ranks.get(flag) > RanksManager.VISITOR_RANK, + flag.getID() + " would mint value outside the market"); + } + // Trading transacts through the hold only (spec principle 1) - a direct + // villager trade would bypass the entire market + assertTrue(ranks.get(Flags.TRADING) > RanksManager.VISITOR_RANK); + } + + @Test + void testBandControlsHurtVillagers() { + // SAFE forbids it outright, rougher bands allow it - and Stage 6 punishes it + assertEquals(RanksManager.MEMBER_RANK, + PortFlags.ranks(RanksManager.MEMBER_RANK, Set.of(), Set.of()).get(Flags.HURT_VILLAGERS)); + assertEquals(RanksManager.VISITOR_RANK, + PortFlags.ranks(RanksManager.VISITOR_RANK, Set.of(), Set.of()).get(Flags.HURT_VILLAGERS)); + } + + @Test + void testAdminOverridesBothWays() { + // Deny something normally allowed + Map stricter = PortFlags.ranks(0, Set.of(Flags.BED.getID()), Set.of()); + assertTrue(stricter.get(Flags.BED) > RanksManager.VISITOR_RANK); + // ... and re-open something the deny list closes + Map looser = PortFlags.ranks(0, Set.of(), Set.of(Flags.HARVEST.getID())); + assertEquals(RanksManager.VISITOR_RANK, looser.get(Flags.HARVEST)); + // An allow overrides a deny, so an admin can always open a port back up + Map both = PortFlags.ranks(0, Set.of(Flags.BED.getID()), Set.of(Flags.BED.getID())); + assertEquals(RanksManager.VISITOR_RANK, both.get(Flags.BED)); + } + + @Test + void testPolicyIsAllowByDefault() { + // The point of the inversion, and the thing that must not quietly rot: + // the policy walks Flags.values() at RUNTIME rather than naming an + // allow list, so a flag added by a future BentoBox is open at a port on + // day one instead of silently locking something. (The compile target, + // BentoBox 3.18.1, already has a different flag set from core HEAD - + // exactly the drift this protects against.) + Map ranks = ranks(); + long allowed = ranks.values().stream().filter(r -> r == RanksManager.VISITOR_RANK).count(); + long denied = ranks.values().stream().filter(r -> r > RanksManager.VISITOR_RANK).count(); + assertTrue(allowed > denied, + "Denied " + denied + " of " + ranks.size() + " - is this still allow-by-default?"); + assertFalse(PortFlags.deniedIds().isEmpty()); + } +} From 3c8ca99fb15631086505de42ea2346bcc9e24258 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 23:25:08 -0700 Subject: [PATCH 052/112] Stage 6b: customs scans, contraband, and the smuggler's chase Detection is not a fine. Being caught at the border with cargo you should not have starts a pursuit, and the water between you and the horizon is a decision: run for the border, fight the patrol, or jettison - which clears you at the cost of the cargo, and leaves it floating for anyone to pick up. CustomsService detects entry by tracking which island's protection range a player is inside and firing on the transition, so rowing in and warping in are the same event: the spec asks for a scan on every entry, and a teleport is an entry. A per-island scan cooldown stops a smuggler bouncing across the border re-rolling the dice; a flee flag means running is remembered, and coming back to that port inside the window skips the roll entirely. The risk/reward shape has an invariant worth naming, and now a test: the bands that buy contraband are the bands that do not scan for it. Safe ports search everyone and refuse to deal; anarchic ports do neither. If that inverts, smuggling is either free money or impossible. PoliceDispatch is the minimum force that makes a chase real - a sea patrol surfacing between the smuggler and the port, so running for open water is a live option and running for harbour is not. Police are PDC-tagged and drop nothing at all: loot-bearing police would make a criminal record an iron farm. 187 tests green. 6c (full police roster, wanted response, bounty nameplates) is the last session of the stage. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 52 +++ docs/PROGRESS.md | 40 ++ .../world/bentobox/tradewinds/Settings.java | 111 +++++ .../world/bentobox/tradewinds/TradeWinds.java | 14 + .../bentobox/tradewinds/crime/Contraband.java | 76 ++++ .../tradewinds/crime/CustomsListener.java | 111 +++++ .../tradewinds/crime/CustomsService.java | 405 ++++++++++++++++++ .../tradewinds/crime/PoliceDispatch.java | 145 +++++++ .../tradewinds/economy/MarketService.java | 8 + src/main/resources/config.yml | 43 ++ src/main/resources/locales/en-US.yml | 10 +- .../tradewinds/crime/ContrabandTest.java | 88 ++++ 12 files changed, 1101 insertions(+), 2 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/crime/Contraband.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/CustomsListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/CustomsService.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java create mode 100644 src/test/java/world/bentobox/tradewinds/crime/ContrabandTest.java diff --git a/TESTING.md b/TESTING.md index 6f89958..6f9dff7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -642,3 +642,55 @@ rank *except* an explicit deny list. reputation loss off innocents by attacking them would be a weapon. - [ ] Killing a **Wanted or Fugitive** player pays their bounty to the killer with no penalty, and the bounty is cleared — kill them again and it pays **nothing**. + +## Stage 6b — Customs and contraband + +Contraband is **sugar** by default (`economy.unstamped-sellables`) — the one thing a +player can make and still sell, and the deliberate hole in the two-economies rule. +Grow some, or `/give` yourself sugar, and put it in your **hold** (a pouch, expander +or chest boat — loose pockets are not cargo and are not scanned). + +### 6b-i — The scan +- [ ] Row or warp into a **SAFE** island's protection range carrying no contraband: + "Customs board you, look through the hold, and wave you on." (Set + `announce-clean-scans: false` to silence it.) +- [ ] Do it again immediately — no second scan, the per-island cooldown holds + (10 min default). This is what stops re-entry dice-rolling. +- [ ] Enter a **SAFE** island with sugar in the hold: title card "CUSTOMS — Patrol + dispatched", and drowned/guardians surface **between you and the island**. +- [ ] Warping in triggers the scan exactly like rowing in. +- [ ] Enter an **ANARCHIC** island with contraband: no scan at all, ever. +- [ ] Being **Upstanding** noticeably reduces how often you are searched; being an + Offender or worse increases it. + +### 6b-ii — The chase, and its three ways out +- [ ] **Jettison**: drop the sugar mid-chase. "Your cargo goes over the side" — the + patrol despawns, no fine, no reputation loss. The dropped items float and + **anyone else can pick them up** (this is where piracy comes from). +- [ ] **Run**: get more than 400 blocks beyond the protection range. "Open water..." + — you keep the cargo, no penalty. +- [ ] **Caught**: let a patrol unit reach you (within 4 blocks) or let one hit you. + All contraband is seized, a fine is taken, and reputation drops by 30. + Check the balance actually moved and the hold is actually empty of sugar. +- [ ] Fight the patrol instead: killing a unit costs reputation (KILL_POLICE)... +- [ ] ...and **police drop nothing at all** — no items, no XP. Kill several and + confirm. (Loot-bearing police would make a criminal record an iron farm.) +- [ ] The chase ends by itself after 2 minutes if nothing else resolves it, and the + patrol despawns. Log out mid-chase and the patrol is cleaned up too. + +### 6b-iii — The flee flag +- [ ] After escaping a chase, go back to **that same island** within 20 minutes: + "[name] remembers you... the patrol is already coming." No scan roll — the + patrol launches immediately. +- [ ] A *different* island still rolls normally. The flag is per-island. +- [ ] The flag expires after 20 minutes. + +### 6b-iv — Selling contraband +- [ ] Try to sell sugar at a **SAFE** or **POLICED** island: refused, with the trader + telling you to try a rougher port, and the dull "no" sound. +- [ ] Sell it at a **FRONTIER**, **LAWLESS** or **ANARCHIC** island: it sells, with no + customs stamp needed. This is the only way to turn farmed goods into money. +- [ ] `illegal-trade.safest-contraband-buyer: SAFE` makes every port buy it; + `ANARCHIC` makes it almost unsellable. +- [ ] `illegal-trade.enabled: false` removes all of it: no scans, no patrols, and + sugar becomes an ordinary unsellable homemade good. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index df6a877..ffaf690 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,46 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 6b — customs and contraband (2026-07-31) + +The scan on entering island space, and the chase after it. Detection is +deliberately **not** a fine (spec section 6): being caught at the border starts +a pursuit, and the water between you and the horizon is a decision - **run**, +**fight**, or **jettison**, which clears you at the cost of the cargo and +leaves it floating for anyone to take. That last one is the seed of piracy. + +`CustomsService` owns the loop. Entry is detected by tracking which island's +protection range a player is inside and firing on the transition, so rowing in +and warping in are the same event - the spec asks for a scan on *every* entry, +and a teleport is an entry. + +Two counters stop the obvious abuses, both from the spec: +- a **per-island scan cooldown**, so a smuggler cannot bounce across the border + re-rolling the dice until they get a pass; +- a **flee flag**, so running is remembered: return to that port inside 20 + minutes and there is no roll at all, the patrol just launches. + +The risk/reward shape has an invariant worth stating out loud, and there is now +a test for it: **the bands that buy contraband are the bands that do not scan +for it**. Safe ports search everyone and refuse to deal; anarchic ports do +neither. If that ever inverts, smuggling becomes either free money or +impossible. Standing tilts the odds both ways, which is where "positive rep +must pay" finally bites. + +`PoliceDispatch` is the minimum force that makes a chase real - a sea patrol +surfacing *between* the smuggler and the port, so running for open water is a +live option and running for the harbour is not. Stage 6c builds the rest on it. +Police are PDC-tagged, non-persistent, and **drop nothing at all**: loot-bearing +police would turn a criminal record into an iron farm, which is exactly +backwards. + +Contraband selling is band-gated (`safest-contraband-buyer`, default FRONTIER), +so a smuggling run is a voyage outward rather than a shortcut. + +187 tests green. Open question 5 (scan/flag tuning) now has defaults to argue +with rather than blanks: 10 min scan cooldown, 20 min flee flag, 4-block arrest +radius, 400-block break-off, 120-second chase cap. + ## Stage 6a — the law: port flags, reputation, fines (2026-07-31) **The flag audit, finally done properly.** Three separate playtest bugs (no boat diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 8ce5e69..cd54a63 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -616,6 +616,93 @@ private static Map defaultEncounterChance() { @ConfigEntry(path = "illegal-trade.enabled") private boolean illegalTradeEnabled = true; + @ConfigComment("The safest security band that will buy contraband at all. Safer ports refuse it,") + @ConfigComment("which is what pushes smuggling runs outward - crime pays, into danger.") + @ConfigComment("One of SAFE, POLICED, FRONTIER, LAWLESS, ANARCHIC.") + @ConfigEntry(path = "illegal-trade.safest-contraband-buyer") + private String safestContrabandBuyer = "FRONTIER"; + + @ConfigComment("Chance (0-1) that entering an island's space triggers a customs scan, per band.") + @ConfigComment("Safe space searches everyone; out in the lawless bands nobody is looking.") + @ConfigEntry(path = "illegal-trade.scan-chance") + private Map scanChance = defaultScanChance(); + + @ConfigComment("Scan chance multipliers by standing: a clean name is worth something at the") + @ConfigComment("border, and a known offender is searched harder.") + @ConfigEntry(path = "illegal-trade.scan-upstanding-factor") + private double scanUpstandingFactor = 0.4; + @ConfigEntry(path = "illegal-trade.scan-offender-factor") + private double scanOffenderFactor = 1.6; + + @ConfigComment("Minutes before the same island will scan the same player again. Without this a") + @ConfigComment("smuggler could bounce across the border re-rolling until they got a pass.") + @ConfigEntry(path = "illegal-trade.scan-cooldown-minutes") + private int scanCooldownMinutes = 10; + + @ConfigComment("Minutes an island remembers a smuggler who ran from its patrol. While flagged,") + @ConfigComment("re-entering that island's space skips the roll: the patrol simply launches.") + @ConfigEntry(path = "illegal-trade.flee-flag-minutes") + private int fleeFlagMinutes = 20; + + @ConfigComment("Customs patrol size per security band. 0 means that band has nobody to send.") + @ConfigEntry(path = "illegal-trade.patrol-size") + private Map patrolSize = defaultPatrolSize(); + + @ConfigComment("How close a patrol unit must get to make the arrest, in blocks.") + @ConfigEntry(path = "illegal-trade.caught-radius") + private double caughtRadius = 4.0; + + @ConfigComment("How far beyond the island's protection range a smuggler must get to shake the") + @ConfigComment("chase, and the longest a chase can run before they are counted as away.") + @ConfigEntry(path = "illegal-trade.chase-break-off-distance") + private int chaseBreakOffDistance = 400; + @ConfigEntry(path = "illegal-trade.chase-seconds") + private int chaseSeconds = 120; + + @ConfigComment("Fine per contraband item seized when caught, on top of losing the cargo and") + @ConfigComment("the reputation. A player who cannot cover it pays what they have.") + @ConfigEntry(path = "illegal-trade.smuggling-fine-per-item") + private double smugglingFinePerItem = 5.0; + + @ConfigComment("Tell players when a scan finds nothing. On by default: being waved through is") + @ConfigComment("how a player learns the mechanic exists before it costs them anything.") + @ConfigEntry(path = "illegal-trade.announce-clean-scans") + private boolean announceCleanScans = true; + + private static Map defaultScanChance() { + Map map = new HashMap<>(); + map.put("SAFE", 0.9); + map.put("POLICED", 0.6); + map.put("FRONTIER", 0.3); + map.put("LAWLESS", 0.1); + map.put("ANARCHIC", 0.0); + return map; + } + + private static Map defaultPatrolSize() { + Map map = new HashMap<>(); + map.put("SAFE", 4); + map.put("POLICED", 3); + map.put("FRONTIER", 2); + map.put("LAWLESS", 1); + map.put("ANARCHIC", 0); + return map; + } + + /** + * The safest band that still buys contraband, parsed from config. + * + * @return the band + */ + public world.bentobox.tradewinds.galaxy.SecurityBand safestContrabandBuyer() { + try { + return world.bentobox.tradewinds.galaxy.SecurityBand + .valueOf(safestContrabandBuyer.toUpperCase(java.util.Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + return world.bentobox.tradewinds.galaxy.SecurityBand.FRONTIER; + } + } + /* DEBUG */ @ConfigComment("Log detailed [TradeWinds DEBUG] lines around galaxy generation, travel and trade.") @ConfigEntry(path = "debug") @@ -2759,6 +2846,30 @@ public void setConcurrentIslands(int concurrentIslands) { public void setBootyChance(double bootyChance) { this.bootyChance = bootyChance; } public List getBootyTable() { return bootyTable; } public void setBootyTable(List bootyTable) { this.bootyTable = bootyTable; } + public String getSafestContrabandBuyer() { return safestContrabandBuyer; } + public void setSafestContrabandBuyer(String v) { this.safestContrabandBuyer = v; } + public Map getScanChance() { return scanChance; } + public void setScanChance(Map v) { this.scanChance = v; } + public double getScanUpstandingFactor() { return scanUpstandingFactor; } + public void setScanUpstandingFactor(double v) { this.scanUpstandingFactor = v; } + public double getScanOffenderFactor() { return scanOffenderFactor; } + public void setScanOffenderFactor(double v) { this.scanOffenderFactor = v; } + public int getScanCooldownMinutes() { return scanCooldownMinutes; } + public void setScanCooldownMinutes(int v) { this.scanCooldownMinutes = v; } + public int getFleeFlagMinutes() { return fleeFlagMinutes; } + public void setFleeFlagMinutes(int v) { this.fleeFlagMinutes = v; } + public Map getPatrolSize() { return patrolSize; } + public void setPatrolSize(Map v) { this.patrolSize = v; } + public double getCaughtRadius() { return caughtRadius; } + public void setCaughtRadius(double v) { this.caughtRadius = v; } + public int getChaseBreakOffDistance() { return chaseBreakOffDistance; } + public void setChaseBreakOffDistance(int v) { this.chaseBreakOffDistance = v; } + public int getChaseSeconds() { return chaseSeconds; } + public void setChaseSeconds(int v) { this.chaseSeconds = v; } + public double getSmugglingFinePerItem() { return smugglingFinePerItem; } + public void setSmugglingFinePerItem(double v) { this.smugglingFinePerItem = v; } + public boolean isAnnounceCleanScans() { return announceCleanScans; } + public void setAnnounceCleanScans(boolean v) { this.announceCleanScans = v; } public boolean isIllegalTradeEnabled() { return illegalTradeEnabled; } public void setIllegalTradeEnabled(boolean illegalTradeEnabled) { this.illegalTradeEnabled = illegalTradeEnabled; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 2862a40..80afdfd 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -36,6 +36,8 @@ import world.bentobox.tradewinds.commands.TWTradeCommand; import world.bentobox.tradewinds.commands.TWWarpCommand; import world.bentobox.tradewinds.crime.CrimeListener; +import world.bentobox.tradewinds.crime.CustomsListener; +import world.bentobox.tradewinds.crime.CustomsService; import world.bentobox.tradewinds.crime.ReputationService; import world.bentobox.tradewinds.crime.Standing; import world.bentobox.tradewinds.dataobjects.IslandDataManager; @@ -104,6 +106,7 @@ public class TradeWinds extends GameModeAddon { private @Nullable EncounterService encounterService; private ReputationService reputationService; private CrimeListener crimeListener; + private CustomsService customsService; private NamespacedKey policeKey; private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; @@ -262,6 +265,10 @@ public void onEnable() { reputationService.start(); crimeListener = new CrimeListener(this); registerListener(crimeListener); + // Customs: the scan on entering island space, and the chase after it + customsService = new CustomsService(this); + customsService.start(); + registerListener(new CustomsListener(this)); // Residents survive the night: no mob targeting, tether, respawn registerListener(new ResidentProtectionListener()); residentAuditTask = new ResidentAuditTask(this); @@ -291,6 +298,9 @@ public void onDisable() { if (reputationService != null) { reputationService.stop(); } + if (customsService != null) { + customsService.stop(); + } if (chartHolograms != null) { chartHolograms.clearAll(); } @@ -502,6 +512,10 @@ public CrimeListener getCrimeListener() { return crimeListener; } + public CustomsService getCustomsService() { + return customsService; + } + /** * The PDC key marking an entity as a police unit: dispatched by the law, * never drops loot, and killing one is a crime. diff --git a/src/main/java/world/bentobox/tradewinds/crime/Contraband.java b/src/main/java/world/bentobox/tradewinds/crime/Contraband.java new file mode 100644 index 0000000..2a3472c --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/Contraband.java @@ -0,0 +1,76 @@ +package world.bentobox.tradewinds.crime; + +import java.util.Set; + +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * What customs would rather you were not carrying. + *

+ * Contraband is the one thing a player can make and still sell (spec + * 5.0): everything else traders buy must carry a customs stamp, so money only + * ever enters the game through trade margins. Contraband is the deliberate + * hole in that rule, and scan risk is what it is priced against - crime pays, + * into danger (principle 4). + *

+ * Pure logic, no Bukkit: which bands deal in it, and how likely a search is. + * The material list itself lives in config. + * + * @author tastybento + */ +public final class Contraband { + + private Contraband() { + // Static use only + } + + /** + * Whether a port of this band deals in contraband at all. The safest ports + * refuse it outright, which is what pushes smuggling runs outward into the + * bands where the police are thinner but the pirates are not. + * + * @param band the island's security band + * @param safestBuyer the safest band that will still buy, from config + * @return true if this port buys contraband + */ + public static boolean buysContraband(SecurityBand band, SecurityBand safestBuyer) { + return band.ordinal() >= safestBuyer.ordinal(); + } + + /** + * Whether a material is contraband under the configured list. + * + * @param materialName the material's name + * @param contrabandNames configured contraband material names + * @param illegalTradeEnabled the master gate + * @return true if it is contraband + */ + public static boolean isContraband(String materialName, Set contrabandNames, + boolean illegalTradeEnabled) { + return illegalTradeEnabled && contrabandNames.contains(materialName); + } + + /** + * The chance a customs scan actually happens, given the band's base chance + * and the smuggler's standing. A clean name is worth something at the + * border: an upstanding trader is waved through more often, a known + * offender is searched harder. This is the "positive rep must pay" half of + * spec section 7. + * + * @param bandChance the band's base scan chance (0-1) + * @param standing the player's standing + * @param upstandingFactor multiplier applied to an upstanding player + * @param offenderFactor multiplier applied to an offender or worse + * @return the effective chance, clamped to [0, 1] + */ + public static double scanChance(double bandChance, Standing standing, double upstandingFactor, + double offenderFactor) { + double chance = bandChance; + if (standing == Standing.UPSTANDING) { + chance *= upstandingFactor; + } else if (standing != Standing.CLEAN) { + chance *= offenderFactor; + } + return Math.clamp(chance, 0.0, 1.0); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/CustomsListener.java b/src/main/java/world/bentobox/tradewinds/crime/CustomsListener.java new file mode 100644 index 0000000..c93f903 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/CustomsListener.java @@ -0,0 +1,111 @@ +package world.bentobox.tradewinds.crime; + +import org.bukkit.entity.Boat; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.vehicle.VehicleMoveEvent; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.TWWarpCompletedEvent; + +/** + * Drives customs: notices players entering island space (rowed or warped), + * turns a police hit into an arrest, and enforces the rule that police never + * drop anything. + * + * @author tastybento + */ +public class CustomsListener implements Listener { + + private final TradeWinds addon; + + public CustomsListener(TradeWinds addon) { + this.addon = addon; + } + + private CustomsService customs() { + return addon.getCustomsService(); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerMove(PlayerMoveEvent event) { + if (event.getFrom().getBlockX() == event.getTo().getBlockX() + && event.getFrom().getBlockZ() == event.getTo().getBlockZ()) { + return; + } + customs().updatePosition(event.getPlayer()); + } + + /** + * A seated passenger's own movement events are unreliable, so the boat's + * are watched too - a smuggler is nearly always in a boat. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onVehicleMove(VehicleMoveEvent event) { + if (!(event.getVehicle() instanceof Boat boat) || boat.getPassengers().isEmpty()) { + return; + } + if (event.getFrom().getBlockX() == event.getTo().getBlockX() + && event.getFrom().getBlockZ() == event.getTo().getBlockZ()) { + return; + } + boat.getPassengers().stream().filter(Player.class::isInstance).map(Player.class::cast) + .forEach(customs()::updatePosition); + } + + /** + * Warping in is an entry like any other: the scan does not care how you + * arrived (spec section 6). + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onWarpArrival(TWWarpCompletedEvent event) { + customs().updatePosition(event.getPlayer()); + } + + /** + * A patrol landing a hit ends the chase then and there. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDamage(EntityDamageByEntityEvent event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + if (isPoliceSource(event) && customs().isChased(player.getUniqueId())) { + customs().onPoliceHit(player); + } + } + + private boolean isPoliceSource(EntityDamageByEntityEvent event) { + if (customs().getPolice().isPolice(event.getDamager())) { + return true; + } + return event.getDamager() instanceof Projectile projectile + && projectile.getShooter() instanceof org.bukkit.entity.Entity shooter + && customs().getPolice().isPolice(shooter); + } + + /** + * Police drop nothing at all - no items, no experience. A criminal record + * must never become a farm (spec section 7). + */ + @EventHandler(priority = EventPriority.HIGHEST) + public void onPoliceDeath(EntityDeathEvent event) { + if (customs().getPolice().isPolice(event.getEntity())) { + event.getDrops().clear(); + event.setDroppedExp(0); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + customs().forget(event.getPlayer().getUniqueId()); + addon.getCrimeListener().forget(event.getPlayer().getUniqueId()); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java new file mode 100644 index 0000000..7e22eff --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java @@ -0,0 +1,405 @@ +package world.bentobox.tradewinds.crime; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.hooks.VaultHook; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Customs: the scan on entering island space, and the chase that follows a + * detection. + *

+ * Detection is deliberately not a fine (spec section 6). Being caught + * at the border with cargo you should not have starts a chase, and the water + * between you and the horizon is a decision: run for the border, + * fight the patrol, or jettison the cargo - which clears you, at + * the cost of the cargo, and leaves it floating for anyone to pick up. That + * last one is where emergent piracy comes from. + *

+ * Two counters stop the obvious abuses: a per-island scan cooldown so a + * smuggler cannot bounce in and out re-rolling the dice, and a flee flag so + * that running from a scan is remembered - come back to that port soon and + * there is no roll at all, the patrol simply launches. + * + * @author tastybento + */ +public class CustomsService { + + /** How often the chase is re-examined, in ticks. */ + private static final long CHASE_PERIOD = 20L; + + /** + * One chase in progress. + * + * @param island the island whose customs are chasing + * @param started epoch millis + * @param units the patrol dispatched + */ + private record Chase(IslandSpec island, long started, List units) { + } + + private final TradeWinds addon; + private final PoliceDispatch police; + /** Which island's space each player is currently inside. */ + private final Map insideIsland = new ConcurrentHashMap<>(); + private final Map chases = new ConcurrentHashMap<>(); + /** player+island -> epoch millis of the last scan there. */ + private final Map scanCooldowns = new ConcurrentHashMap<>(); + /** player+island -> epoch millis until which the player is flagged there. */ + private final Map fleeFlags = new ConcurrentHashMap<>(); + private BukkitTask task; + + public CustomsService(TradeWinds addon) { + this.addon = addon; + this.police = new PoliceDispatch(addon); + } + + public PoliceDispatch getPolice() { + return police; + } + + public void start() { + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::tick, CHASE_PERIOD, CHASE_PERIOD); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + chases.values().forEach(chase -> police.recall(chase.units())); + chases.clear(); + } + + /** + * Whether the crime layer is running at all. + */ + private boolean enabled() { + return addon.getSettings().isCrimeEnabled() && addon.getSettings().isIllegalTradeEnabled(); + } + + /** + * The configured contraband materials. + * + * @return material names customs care about + */ + public Set contrabandNames() { + return new HashSet<>(addon.getSettings().getUnstampedSellables()); + } + + /** + * Whether a material is contraband. + * + * @param material the material + * @return true if customs would confiscate it + */ + public boolean isContraband(Material material) { + return Contraband.isContraband(material.name(), contrabandNames(), + addon.getSettings().isIllegalTradeEnabled()); + } + + /** + * How much contraband a player is carrying in their hold. Loose pockets do + * not count: like everything else in this game, cargo means the hold + * (spec principle 1). + * + * @param player the player + * @return total contraband items aboard + */ + public int contrabandAboard(Player player) { + int total = 0; + for (String name : contrabandNames()) { + Material material = Material.matchMaterial(name); + if (material != null) { + total += addon.getHoldService().count(player, material, stack -> true); + } + } + return total; + } + + /** + * Called when a player enters an island's space, however they got there. + * + * @param player the arriving player + * @param island the island + */ + public void onEntry(Player player, IslandSpec island) { + if (!enabled() || player.getGameMode() != org.bukkit.GameMode.SURVIVAL) { + return; + } + if (chases.containsKey(player.getUniqueId())) { + return; // Already being chased - one at a time + } + String key = key(player, island); + boolean flagged = isFlagged(key); + if (!flagged && !rollScan(player, island, key)) { + return; + } + scanCooldowns.put(key, System.currentTimeMillis()); + int aboard = contrabandAboard(player); + if (aboard <= 0) { + if (!flagged && addon.getSettings().isAnnounceCleanScans()) { + User.getInstance(player).sendMessage("tradewinds.customs.clean"); + } + return; + } + detect(player, island, aboard, flagged); + } + + /** + * Roll the dice on a scan, honouring the per-island cooldown that stops a + * smuggler bouncing across the border re-rolling until they get a pass. + */ + private boolean rollScan(Player player, IslandSpec island, String key) { + Long last = scanCooldowns.get(key); + long cooldown = addon.getSettings().getScanCooldownMinutes() * 60_000L; + if (last != null && System.currentTimeMillis() - last < cooldown) { + return false; + } + double base = addon.getSettings().getScanChance().getOrDefault(island.band().name(), 0.0); + double chance = Contraband.scanChance(base, addon.getReputationService().standing(player.getUniqueId()), + addon.getSettings().getScanUpstandingFactor(), addon.getSettings().getScanOffenderFactor()); + return Math.random() < chance; + } + + /** + * Caught at the border: launch the patrol and start the clock. + */ + private void detect(Player player, IslandSpec island, int aboard, boolean flagged) { + User user = User.getInstance(player); + user.sendMessage(flagged ? "tradewinds.customs.flagged" : "tradewinds.customs.detected", + "[name]", island.name(), TextVariables.NUMBER, String.valueOf(aboard)); + List units = police.dispatch(player, island); + chases.put(player.getUniqueId(), new Chase(island, System.currentTimeMillis(), units)); + } + + /** + * Progress every chase: cleared, escaped, caught, or timed out. + */ + void tick() { + chases.forEach((id, chase) -> { + Player player = Bukkit.getPlayer(id); + if (player == null || player.isDead() || !addon.inWorld(player.getWorld())) { + end(id, chase); + return; + } + // Jettisoned: the cargo is gone, and so is the reason to chase + if (contrabandAboard(player) <= 0) { + User.getInstance(player).sendMessage("tradewinds.customs.jettisoned"); + end(id, chase); + return; + } + // Caught: a unit closed to arm's reach + if (nearestUnitDistance(player, chase) <= addon.getSettings().getCaughtRadius()) { + caught(player, chase); + end(id, chase); + return; + } + // Escaped: across the border and away + double fromIsland = Math.sqrt(chase.island().distanceSquared(player.getLocation().getBlockX(), + player.getLocation().getBlockZ())); + if (fromIsland > addon.getSettings().getIslandProtectionRange() + + addon.getSettings().getChaseBreakOffDistance()) { + escaped(player, chase); + end(id, chase); + return; + } + if (System.currentTimeMillis() - chase.started() > addon.getSettings().getChaseSeconds() * 1000L) { + escaped(player, chase); + end(id, chase); + } + }); + } + + private double nearestUnitDistance(Player player, Chase chase) { + return chase.units().stream().filter(Entity::isValid) + .mapToDouble(unit -> unit.getLocation().distance(player.getLocation())).min() + .orElse(Double.MAX_VALUE); + } + + /** + * Caught with the cargo: confiscation, a fine, and a reputation hit. The + * cargo is destroyed rather than dropped - customs seized it. + */ + void caught(Player player, Chase chase) { + int seized = 0; + for (String name : contrabandNames()) { + Material material = Material.matchMaterial(name); + if (material != null) { + seized += addon.getHoldService().remove(player, material, Integer.MAX_VALUE, stack -> true); + } + } + double fine = seized * addon.getSettings().getSmugglingFinePerItem(); + VaultHook vault = addon.getPlugin().getVault().orElse(null); + User user = User.getInstance(player); + if (vault != null && fine > 0) { + // Take what they have if they cannot cover it - a debt would just + // be another thing to run from + double payable = Math.min(fine, vault.getBalance(user)); + vault.withdraw(user, payable); + fine = payable; + } + user.sendMessage("tradewinds.customs.caught", TextVariables.NUMBER, String.valueOf(seized), + "[amount]", vault == null ? "0" : vault.format(fine), "[name]", chase.island().name()); + addon.getReputationService().record(player, Crime.SMUGGLING); + } + + /** + * Ran for it and made it. The port remembers: come back soon and there is + * no scan roll, the patrol simply launches. + */ + private void escaped(Player player, Chase chase) { + String key = key(player, chase.island()); + fleeFlags.put(key, System.currentTimeMillis() + addon.getSettings().getFleeFlagMinutes() * 60_000L); + User.getInstance(player).sendMessage("tradewinds.customs.escaped", "[name]", chase.island().name()); + } + + private void end(UUID id, Chase chase) { + chases.remove(id); + police.recall(chase.units()); + } + + /** + * Whether a player is currently flagged at an island. + */ + private boolean isFlagged(String key) { + Long until = fleeFlags.get(key); + if (until == null) { + return false; + } + if (System.currentTimeMillis() > until) { + fleeFlags.remove(key); + return false; + } + return true; + } + + /** + * Whether a player is being chased right now. + * + * @param playerId the player + * @return true during a chase + */ + public boolean isChased(UUID playerId) { + return chases.containsKey(playerId); + } + + /** + * A police unit landed a hit: that counts as caught, wherever the chase had + * got to. + * + * @param player the smuggler + */ + public void onPoliceHit(Player player) { + Chase chase = chases.get(player.getUniqueId()); + if (chase != null && contrabandAboard(player) > 0) { + caught(player, chase); + end(player.getUniqueId(), chase); + } + } + + /** + * Track which island's space a player is in, and fire the scan on entry. + * Called from movement and from warp arrival, so rowing in and warping in + * are treated identically (spec section 6: a scan on every entry). + * + * @param player the player + */ + public void updatePosition(Player player) { + if (!addon.inWorld(player.getWorld()) || addon.getOverWorld() == null + || !player.getWorld().equals(addon.getOverWorld())) { + insideIsland.remove(player.getUniqueId()); + return; + } + Optional here = islandSpaceAt(player); + String was = insideIsland.get(player.getUniqueId()); + if (here.isEmpty()) { + insideIsland.remove(player.getUniqueId()); + return; + } + String now = key(here.get()); + if (now.equals(was)) { + return; + } + insideIsland.put(player.getUniqueId(), now); + onEntry(player, here.get()); + } + + /** + * The island whose protection range covers a player, if any - "island + * space" as the rest of the game means it. + */ + private Optional islandSpaceAt(Player player) { + int x = player.getLocation().getBlockX(); + int z = player.getLocation().getBlockZ(); + int range = addon.getSettings().getIslandProtectionRange(); + return addon.getGalaxyEngine(addon.getOverWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(s -> s.distanceSquared(x, z) <= (long) range * range).findFirst(); + } + + /** + * Whether this port deals in contraband at all. + * + * @param band the island's band + * @return true if it buys + */ + public boolean buysContraband(SecurityBand band) { + SecurityBand safest = addon.getSettings().safestContrabandBuyer(); + return Contraband.buysContraband(band, safest); + } + + /** + * Whether an item may be sold at this island - the contraband rule on top + * of the customs stamp. + * + * @param spec the island + * @param item the item + * @return true if the port will take it + */ + public boolean sellableAt(IslandSpec spec, ItemStack item) { + if (item == null) { + return false; + } + if (!isContraband(item.getType())) { + return true; + } + return buysContraband(spec.band()); + } + + private static String key(IslandSpec spec) { + return spec.cellX() + "," + spec.cellZ(); + } + + private static String key(Player player, IslandSpec spec) { + return player.getUniqueId() + "@" + key(spec); + } + + /** + * Forget a player's transient customs state on logout. + * + * @param playerId the player + */ + public void forget(UUID playerId) { + insideIsland.remove(playerId); + Chase chase = chases.remove(playerId); + if (chase != null) { + police.recall(chase.units()); + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java new file mode 100644 index 0000000..ef2f794 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java @@ -0,0 +1,145 @@ +package world.bentobox.tradewinds.crime; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Location; +import org.bukkit.entity.Drowned; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Guardian; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Mob; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.Vector; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Launches a customs patrol from an island toward a smuggler. + *

+ * Police are PDC-tagged so they can be told from ordinary sea life, cleaned up + * when a chase ends, and - critically - made to drop nothing. Loot- + * bearing police would turn a criminal record into an iron farm, which is + * exactly backwards (spec section 7). + *

+ * Stage 6b spawns the sea patrol that makes a customs detection a chase. Stage + * 6c builds the rest of the force on this: land golems, pursuing phantoms, and + * the standing response to a wanted player anywhere in policed water. + * + * @author tastybento + */ +public class PoliceDispatch { + + /** How far ahead of the smuggler the patrol surfaces, in blocks. */ + private static final double INTERCEPT_DISTANCE = 22.0; + + private final TradeWinds addon; + + public PoliceDispatch(TradeWinds addon) { + this.addon = addon; + } + + /** + * Send a patrol after a player. Units surface between the smuggler and the + * island they were trying to enter, so running for open sea is a real + * option and running for the port is not. + * + * @param player the smuggler + * @param island the island whose customs launched them + * @return the units dispatched + */ + public List dispatch(Player player, IslandSpec island) { + int count = patrolSize(island.band()); + List units = new ArrayList<>(); + Location from = player.getLocation(); + // Between the player and the island: the patrol comes from the port + Vector toIsland = new Vector(island.centerX() - from.getX(), 0, island.centerZ() - from.getZ()); + if (toIsland.lengthSquared() < 0.01) { + toIsland = new Vector(1, 0, 0); + } + Location spot = from.clone().add(toIsland.normalize().multiply(INTERCEPT_DISTANCE)); + spot.setY(addon.getSettings().getSeaHeight() - 2.0); + for (int i = 0; i < count; i++) { + Location at = spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3); + Entity unit = spawn(at, i % 3 == 0 ? EntityType.GUARDIAN : EntityType.DROWNED, player); + if (unit != null) { + units.add(unit); + } + } + return units; + } + + /** + * How many units a band's customs office can field. Safe space answers + * hard; out in the lawless bands there is nobody to send - which is the + * whole reason to run cargo out there. + */ + int patrolSize(SecurityBand band) { + return addon.getSettings().getPatrolSize().getOrDefault(band.name(), 0); + } + + private Entity spawn(Location at, EntityType type, Player target) { + if (at.getWorld() == null) { + return null; + } + Entity entity = at.getWorld().spawnEntity(at, type); + if (!(entity instanceof LivingEntity living)) { + entity.remove(); + return null; + } + tag(living); + if (living instanceof Drowned drowned) { + // Armed and dangerous: a trident thrower is a threat to a boat + drowned.getEquipment().setItemInMainHand(new ItemStack(org.bukkit.Material.TRIDENT)); + drowned.getEquipment().setItemInMainHandDropChance(0); + } + if (living instanceof Guardian guardian) { + guardian.setRemoveWhenFarAway(true); + } + if (living instanceof Mob mob) { + mob.setTarget(target); + } + return living; + } + + /** + * Mark a unit as police: identifies it for the chase, for cleanup, and for + * the no-drops rule. + * + * @param entity the unit + */ + public void tag(LivingEntity entity) { + entity.getPersistentDataContainer().set(addon.getPoliceKey(), PersistentDataType.BYTE, (byte) 1); + entity.setPersistent(false); + // Police never yield loot or XP - otherwise a wanted player is an + // infinite farm and crime literally pays + entity.setCanPickupItems(false); + if (entity.getEquipment() != null) { + entity.getEquipment().setArmorContents(null); + } + } + + /** + * Whether an entity is a police unit. + * + * @param entity the entity + * @return true if tagged + */ + public boolean isPolice(Entity entity) { + return entity.getPersistentDataContainer().has(addon.getPoliceKey(), PersistentDataType.BYTE); + } + + /** + * Stand a patrol down and remove it. + * + * @param units the units to recall + */ + public void recall(List units) { + units.stream().filter(Entity::isValid).forEach(Entity::remove); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 469bbc6..9b7cdd5 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -218,6 +218,14 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { if (unitPrice.isEmpty() || vault.isEmpty() || amount <= 0) { return 0; } + // The safest ports will not touch contraband at any price, which is + // what makes a smuggling run a voyage outward rather than a shortcut + if (addon.getCustomsService() != null && addon.getCustomsService().isContraband(material) + && !addon.getCustomsService().buysContraband(spec.band())) { + User.getInstance(player).sendMessage("tradewinds.trade.contraband-refused"); + thud(player); + return 0; + } int count = Math.min(addon.getHoldService().count(player, material, sellableFilter()), amount); if (count <= 0) { User.getInstance(player).sendMessage("tradewinds.trade.not-stamped"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 112d0f1..17ce290 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -324,6 +324,49 @@ illegal-trade: # Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling. # Set false for family-friendly servers - removes the entire crime layer cleanly. enabled: true + # The safest band that will buy contraband at all. Safer ports refuse it, which is + # what pushes smuggling runs outward - crime pays, into danger. + # SAFE, POLICED, FRONTIER, LAWLESS or ANARCHIC. + safest-contraband-buyer: FRONTIER + # Chance (0-1) that entering an island's space triggers a customs scan, per band. + # Safe space searches everyone; out in the lawless bands nobody is looking. Note + # these are deliberately the inverse of who will BUY contraband: nowhere both + # deals in it and searches hard for it. + scan-chance: + SAFE: 0.9 + POLICED: 0.6 + FRONTIER: 0.3 + LAWLESS: 0.1 + ANARCHIC: 0.0 + # Scan chance multipliers by standing: a clean name is worth something at the + # border, a known offender is searched harder. + scan-upstanding-factor: 0.4 + scan-offender-factor: 1.6 + # Minutes before the same island scans the same player again. Without this a + # smuggler could bounce across the border re-rolling until they got a pass. + scan-cooldown-minutes: 10 + # Minutes an island remembers a smuggler who ran from its patrol. While flagged, + # re-entering that island's space skips the roll: the patrol simply launches. + flee-flag-minutes: 20 + # Customs patrol size per band. 0 means that band has nobody to send. + patrol-size: + SAFE: 4 + POLICED: 3 + FRONTIER: 2 + LAWLESS: 1 + ANARCHIC: 0 + # How close a patrol unit must get to make the arrest, in blocks. + caught-radius: 4.0 + # How far beyond the protection range a smuggler must get to shake the chase, and + # the longest a chase can run before they are counted as away. + chase-break-off-distance: 400 + chase-seconds: 120 + # Fine per contraband item seized when caught, on top of losing the cargo and the + # reputation. A player who cannot cover it pays what they have. + smuggling-fine-per-item: 5.0 + # Tell players when a scan finds nothing - how a player learns the mechanic exists + # before it costs them anything. + announce-clean-scans: true world: friendly-name: TradeWinds world-name: tradewinds_world diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 3467a27..df13ede 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -70,8 +70,13 @@ tradewinds: fine-cannot-afford: "You cannot afford the fine ([amount])." fine-no-market: "Fines are paid at a trading island, not out here." fine-barred: "'We don't take money from your sort.' Try a rougher port." - status: - clean: "Clean" + customs: + clean: "Customs board you, look through the hold, and wave you on." + detected: "[title]CUSTOMS[subtitle]Patrol dispatched from [name]" + flagged: "[name] remembers you. No inspection this time - the patrol is already coming." + jettisoned: "Your cargo goes over the side. The patrol turns back - nothing to find." + escaped: "Open water. [name] loses you - but its harbourmaster will not forget." + caught: "Boarded. [number] items seized, [amount] in fines, and [name] has your name." hud: navigation: "[name] | [standing] | Dock [distance]m" hologram: @@ -153,6 +158,7 @@ tradewinds: trade: not-at-market: "There is no market here - trade within an island's protection range." not-stamped: "Traders only buy customs-stamped goods (or... certain other merchandise)." + contraband-refused: "The trader looks at what you are offering, then at you. 'Not here. Try a rougher port.'" nothing-to-sell: "Nothing in your hold that this island will pay for." nothing-for-sale: "This island has nothing for sale - it only buys." sold: "Sold [amount] x [material] for $[price]." diff --git a/src/test/java/world/bentobox/tradewinds/crime/ContrabandTest.java b/src/test/java/world/bentobox/tradewinds/crime/ContrabandTest.java new file mode 100644 index 0000000..97e993d --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/crime/ContrabandTest.java @@ -0,0 +1,88 @@ +package world.bentobox.tradewinds.crime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Headless tests of the contraband rules - which ports deal in it, and how + * hard a smuggler is searched. + * + * @author tastybento + */ +class ContrabandTest { + + private static final Set LIST = Set.of("SUGAR"); + + @Test + void testMasterGateRemovesTheWholeMechanic() { + // A family server turns illegal-trade off and nothing is contraband, + // whatever the list says + assertTrue(Contraband.isContraband("SUGAR", LIST, true)); + assertFalse(Contraband.isContraband("SUGAR", LIST, false)); + assertFalse(Contraband.isContraband("BREAD", LIST, true)); + } + + @Test + void testSafePortsRefuseContraband() { + // Crime pays, into danger: the safe ports will not touch it, so a + // smuggling run has to be a voyage outward + assertFalse(Contraband.buysContraband(SecurityBand.SAFE, SecurityBand.FRONTIER)); + assertFalse(Contraband.buysContraband(SecurityBand.POLICED, SecurityBand.FRONTIER)); + assertTrue(Contraband.buysContraband(SecurityBand.FRONTIER, SecurityBand.FRONTIER)); + assertTrue(Contraband.buysContraband(SecurityBand.LAWLESS, SecurityBand.FRONTIER)); + assertTrue(Contraband.buysContraband(SecurityBand.ANARCHIC, SecurityBand.FRONTIER)); + } + + @Test + void testTheBandsThatBuyAreTheBandsThatDoNotScan() { + // The whole risk/reward shape: nowhere both buys contraband AND + // searches hard for it. If this ever inverts, smuggling becomes either + // free money or impossible. + java.util.Map scans = java.util.Map.of(SecurityBand.SAFE, 0.9, + SecurityBand.POLICED, 0.6, SecurityBand.FRONTIER, 0.3, SecurityBand.LAWLESS, 0.1, + SecurityBand.ANARCHIC, 0.0); + for (SecurityBand band : SecurityBand.values()) { + boolean buys = Contraband.buysContraband(band, SecurityBand.FRONTIER); + if (buys) { + assertTrue(scans.get(band) <= 0.3, + band + " both buys contraband and searches hard for it"); + } + } + // ... and scan pressure falls monotonically as the law thins out + double previous = Double.MAX_VALUE; + for (SecurityBand band : SecurityBand.values()) { + assertTrue(scans.get(band) <= previous, "Scan chance rose at " + band); + previous = scans.get(band); + } + } + + @Test + void testStandingMovesTheOddsBothWays() { + // A clean name is worth something at the border (spec 7: positive rep + // must pay), and a known offender is searched harder + double base = 0.5; + assertEquals(0.2, Contraband.scanChance(base, Standing.UPSTANDING, 0.4, 1.6), 1e-9); + assertEquals(0.5, Contraband.scanChance(base, Standing.CLEAN, 0.4, 1.6), 1e-9); + assertEquals(0.8, Contraband.scanChance(base, Standing.OFFENDER, 0.4, 1.6), 1e-9); + assertEquals(0.8, Contraband.scanChance(base, Standing.WANTED, 0.4, 1.6), 1e-9); + } + + @Test + void testScanChanceStaysAProbability() { + // A generous offender factor must not push the chance past certainty, + // nor a band with no customs below zero + assertEquals(1.0, Contraband.scanChance(0.9, Standing.FUGITIVE, 0.4, 5.0), 1e-9); + assertEquals(0.0, Contraband.scanChance(0.0, Standing.FUGITIVE, 0.4, 5.0), 1e-9); + // ANARCHIC has nobody to send: no standing makes it search you + for (Standing standing : Standing.values()) { + assertEquals(0.0, Contraband.scanChance(0.0, standing, 0.4, 1.6), 1e-9); + } + } +} From c968fb0862741fb1e576dfc427d326e1c35a4704 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 23:38:25 -0700 Subject: [PATCH 053/112] Fix customs: title glyph, silent scans after a warp, patrols on land Playtest reported "not sure if it is working". Four bugs behind it. The in the customs title rendered as a literal line-feed glyph: BentoBox splits a [title]/[subtitle] string on the raw marker and hands each half to MiniMessage separately, so became a real newline inside a title component - and a title is one line by definition. The subtitle is the second line. A new test walks every locale string for this, for markers not at the start, and for a subtitle with no title. Worse, warping away mid-chase silently disabled customs for the rest of the session: the chase stayed live and onEntry refuses to start a second one, so the destination never scanned and neither did anywhere after it. Leaving an island's space now ends that island's chase as an escape before the new island is considered. Logging in inside a port also counted as an entry, and the patrol then spawned on the plaza - guardians flopping about on dry land. Login now records position without scanning, and a patrol with no water to launch from becomes a straight confiscation, which is what being caught ashore in a market should mean. Every alert now sends a chat line as well as the title, so a blink does not lose it, and /twadmin customs prints the whole state: contraband aboard, standing, band, effective scan chance, patrol size, chase status. 189 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 19 +++ docs/PROGRESS.md | 26 ++++ .../world/bentobox/tradewinds/TradeWinds.java | 2 + .../commands/AdminCustomsCommand.java | 76 ++++++++++++ .../tradewinds/crime/CustomsListener.java | 11 ++ .../tradewinds/crime/CustomsService.java | 117 +++++++++++++++--- .../tradewinds/crime/PoliceDispatch.java | 41 +++++- src/main/resources/addon.yml | 3 + src/main/resources/locales/en-US.yml | 19 ++- .../crime/CustomsLocaleMarkerTest.java | 82 ++++++++++++ 10 files changed, 374 insertions(+), 22 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/AdminCustomsCommand.java create mode 100644 src/test/java/world/bentobox/tradewinds/crime/CustomsLocaleMarkerTest.java diff --git a/TESTING.md b/TESTING.md index 6f9dff7..f8386ad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -650,6 +650,25 @@ player can make and still sell, and the deliberate hole in the two-economies rul Grow some, or `/give` yourself sugar, and put it in your **hold** (a pouch, expander or chest boat — loose pockets are not cargo and are not scanned). +**Use `/twadmin customs`** — it prints the whole customs state where you stand +(contraband in your hold, your standing, this island's band, your effective scan +chance, patrol size, whether a chase is running). Scans are probabilistic and +cooldowns are invisible, so guessing from behaviour alone is a trap. + +### 6b-0 — Fixes from the first playtest +- [ ] The customs alert reads "CUSTOMS" with "Patrol dispatched from " beneath + it — **no line-feed glyph**. (A `` inside a title half is parsed into a + real newline character, which a title cannot render.) +- [ ] A chat line follows the title and stays in the log, so a blink does not lose it. +- [ ] **Log in inside a port with contraband: nothing happens.** Logging in is not an + entry. Row out and back in and the scan fires normally. +- [ ] **Warp away mid-chase, then into another island's space: the new island scans + you.** (Previously the old chase stayed live and blocked every later scan, so + customs silently stopped working for the rest of the session.) +- [ ] Standing on a plaza with contraband when a scan fires: there is no water for a + patrol to launch from, so customs simply seize the cargo and fine you, with a + message saying so — rather than spawning guardians that flop about on land. + ### 6b-i — The scan - [ ] Row or warp into a **SAFE** island's protection range carrying no contraband: "Customs board you, look through the hold, and wave you on." (Set diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ffaf690..9915119 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -43,6 +43,32 @@ so a smuggling run is a voyage outward rather than a shortcut. with rather than blanks: 10 min scan cooldown, 20 min flee flag, 4-block arrest radius, 400-block break-off, 120-second chase cap. +**First playtest: "not sure if it is working" - four real bugs.** +1. `` inside a `[title]` rendered as a literal line-feed glyph. BentoBox + splits the string on the raw `[subtitle]` marker and hands each half to + MiniMessage separately, so `` became an actual newline character + inside a title component - and a title is one line by definition. The + subtitle IS the second line. `CustomsLocaleMarkerTest` now walks every locale + string and fails on a newline in a title half, a marker that is not at the + start, or a `[subtitle]` with no `[title]`. +2. A title flashes past and is gone. Every customs alert now also sends a chat + line, which persists. +3. **Warping away mid-chase silently disabled customs for the session.** The + chase stayed live, and `onEntry` refuses to start a second one - so the + destination never scanned, and neither did anywhere afterwards. Leaving an + island's space now ends that island's chase (as an escape) *before* the new + island is considered. +4. Logging in inside a port counted as an entry, and the patrol spawned on the + plaza - guardians flopping on dry land. Login now records position without + scanning, and a patrol that can find no water to launch from turns into a + straight confiscation, which is what being caught ashore in a market should + mean anyway. + +Also added `/twadmin customs`: contraband aboard, standing, band, effective scan +chance, patrol size, chase state. Scans are probabilistic and cooldowns are +invisible, so "is it working?" was not answerable from behaviour alone - that +was the real complaint, and a diagnostic is the answer to it. 189 tests green. + ## Stage 6a — the law: port flags, reputation, fines (2026-07-31) **The flag audit, finally done properly.** Three separate playtest bugs (no boat diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 80afdfd..2238fb9 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -25,6 +25,7 @@ import world.bentobox.bentobox.api.configuration.WorldSettings; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.lists.Flags; +import world.bentobox.tradewinds.commands.AdminCustomsCommand; import world.bentobox.tradewinds.commands.AdminIslandsCommand; import world.bentobox.tradewinds.commands.AdminReflagCommand; import world.bentobox.tradewinds.commands.AdminTpIslandCommand; @@ -179,6 +180,7 @@ public void setup() { new AdminIslandsCommand(this); new AdminTpIslandCommand(this); new AdminReflagCommand(this); + new AdminCustomsCommand(this); } }; } diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminCustomsCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminCustomsCommand.java new file mode 100644 index 0000000..9bcacdb --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminCustomsCommand.java @@ -0,0 +1,76 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; +import java.util.Optional; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.crime.Contraband; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Shows what customs would make of you, here, right now. + *

+ * Added after a playtest where the honest report was "not sure if it is + * working": scans are probabilistic, cooldowns and flags are invisible, and a + * title card that flashes past tells you nothing about why. This prints the + * whole state so it can be reasoned about instead of guessed at. + * + * @author tastybento + */ +public class AdminCustomsCommand extends CompositeCommand { + + public AdminCustomsCommand(CompositeCommand parent) { + super(parent, "customs"); + } + + @Override + public void setup() { + setPermission("admin.customs"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.admin.customs.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + int x = user.getLocation().getBlockX(); + int z = user.getLocation().getBlockZ(); + int range = addon.getSettings().getIslandProtectionRange(); + Optional here = addon.getGalaxyEngine(getWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(s -> s.distanceSquared(x, z) <= (long) range * range).findFirst(); + + user.sendMessage("tradewinds.commands.admin.customs.header"); + user.sendMessage("tradewinds.commands.admin.customs.gate", "[value]", + String.valueOf(addon.getSettings().isCrimeEnabled() + && addon.getSettings().isIllegalTradeEnabled())); + user.sendMessage("tradewinds.commands.admin.customs.contraband", "[value]", + String.join(", ", addon.getCustomsService().contrabandNames())); + user.sendMessage("tradewinds.commands.admin.customs.aboard", TextVariables.NUMBER, + String.valueOf(addon.getCustomsService().contrabandAboard(user.getPlayer()))); + user.sendMessage("tradewinds.commands.admin.customs.standing", "[value]", + addon.getPlayerStanding(user, user.getUniqueId())); + if (here.isEmpty()) { + user.sendMessage("tradewinds.commands.admin.customs.open-sea"); + return true; + } + IslandSpec island = here.get(); + double base = addon.getSettings().getScanChance().getOrDefault(island.band().name(), 0.0); + double effective = Contraband.scanChance(base, + addon.getReputationService().standing(user.getUniqueId()), + addon.getSettings().getScanUpstandingFactor(), addon.getSettings().getScanOffenderFactor()); + user.sendMessage("tradewinds.commands.admin.customs.island", "[name]", island.name(), "[band]", + island.band().getDisplayName()); + user.sendMessage("tradewinds.commands.admin.customs.scan-chance", "[value]", + String.format("%.0f%%", effective * 100)); + user.sendMessage("tradewinds.commands.admin.customs.buys", "[value]", + String.valueOf(addon.getCustomsService().buysContraband(island.band()))); + user.sendMessage("tradewinds.commands.admin.customs.patrol", TextVariables.NUMBER, + String.valueOf(addon.getSettings().getPatrolSize().getOrDefault(island.band().name(), 0))); + user.sendMessage("tradewinds.commands.admin.customs.chased", "[value]", + String.valueOf(addon.getCustomsService().isChased(user.getUniqueId()))); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/CustomsListener.java b/src/main/java/world/bentobox/tradewinds/crime/CustomsListener.java index c93f903..44d0e1d 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/CustomsListener.java +++ b/src/main/java/world/bentobox/tradewinds/crime/CustomsListener.java @@ -8,6 +8,7 @@ import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.vehicle.VehicleMoveEvent; @@ -103,6 +104,16 @@ public void onPoliceDeath(EntityDeathEvent event) { } } + /** + * Record where a joining player is without searching them. A player who + * logged out inside a port would otherwise be scanned the instant they + * took a step, which is not an "entry" by any reading. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + customs().updatePosition(event.getPlayer(), false); + } + @EventHandler public void onQuit(PlayerQuitEvent event) { customs().forget(event.getPlayer().getUniqueId()); diff --git a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java index 7e22eff..edf5e26 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java +++ b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java @@ -180,13 +180,39 @@ private boolean rollScan(Player player, IslandSpec island, String key) { /** * Caught at the border: launch the patrol and start the clock. + *

+ * A title alone was not enough in play - it flashes and is gone, and a + * player who blinks has no idea what happened. The chat line stays. */ private void detect(Player player, IslandSpec island, int aboard, boolean flagged) { User user = User.getInstance(player); + List units = police.dispatch(player, island); + if (units.isEmpty()) { + // Nowhere to launch a boat from: the smuggler is ashore in the + // market itself, so there is no chase to have - customs just take + // the cargo off them + seize(player, island); + return; + } user.sendMessage(flagged ? "tradewinds.customs.flagged" : "tradewinds.customs.detected", "[name]", island.name(), TextVariables.NUMBER, String.valueOf(aboard)); - List units = police.dispatch(player, island); + user.sendMessage("tradewinds.customs.detected-chat", "[name]", island.name(), TextVariables.NUMBER, + String.valueOf(aboard)); chases.put(player.getUniqueId(), new Chase(island, System.currentTimeMillis(), units)); + addon.log("Customs at " + island.name() + " detected " + aboard + " contraband on " + player.getName() + + " - patrol of " + units.size() + " dispatched"); + } + + /** + * Confiscation with no chase: the player is standing in the market, and + * there is nowhere for a patrol boat to come from. + */ + private void seize(Player player, IslandSpec island) { + int seized = confiscate(player); + double fine = charge(player, seized); + User.getInstance(player).sendMessage("tradewinds.customs.seized", TextVariables.NUMBER, + String.valueOf(seized), "[amount]", format(fine), "[name]", island.name()); + addon.getReputationService().record(player, Crime.SMUGGLING); } /** @@ -238,6 +264,20 @@ private double nearestUnitDistance(Player player, Chase chase) { * cargo is destroyed rather than dropped - customs seized it. */ void caught(Player player, Chase chase) { + int seized = confiscate(player); + double fine = charge(player, seized); + User.getInstance(player).sendMessage("tradewinds.customs.caught", TextVariables.NUMBER, + String.valueOf(seized), "[amount]", format(fine), "[name]", chase.island().name()); + addon.getReputationService().record(player, Crime.SMUGGLING); + } + + /** + * Take every scrap of contraband out of the hold. + * + * @param player the smuggler + * @return how much was seized + */ + private int confiscate(Player player) { int seized = 0; for (String name : contrabandNames()) { Material material = Material.matchMaterial(name); @@ -245,19 +285,31 @@ void caught(Player player, Chase chase) { seized += addon.getHoldService().remove(player, material, Integer.MAX_VALUE, stack -> true); } } + return seized; + } + + /** + * Charge the smuggling fine, taking what they have if they cannot cover it + * - a debt would only be one more thing to run from. + * + * @param player the smuggler + * @param seized how much contraband was taken + * @return what was actually paid + */ + private double charge(Player player, int seized) { double fine = seized * addon.getSettings().getSmugglingFinePerItem(); VaultHook vault = addon.getPlugin().getVault().orElse(null); - User user = User.getInstance(player); - if (vault != null && fine > 0) { - // Take what they have if they cannot cover it - a debt would just - // be another thing to run from - double payable = Math.min(fine, vault.getBalance(user)); - vault.withdraw(user, payable); - fine = payable; + if (vault == null || fine <= 0) { + return 0; } - user.sendMessage("tradewinds.customs.caught", TextVariables.NUMBER, String.valueOf(seized), - "[amount]", vault == null ? "0" : vault.format(fine), "[name]", chase.island().name()); - addon.getReputationService().record(player, Crime.SMUGGLING); + User user = User.getInstance(player); + double payable = Math.min(fine, vault.getBalance(user)); + vault.withdraw(user, payable); + return payable; + } + + private String format(double amount) { + return addon.getPlugin().getVault().map(v -> v.format(amount)).orElse(String.valueOf(amount)); } /** @@ -322,6 +374,18 @@ public void onPoliceHit(Player player) { * @param player the player */ public void updatePosition(Player player) { + updatePosition(player, true); + } + + /** + * Track which island's space a player is in. + * + * @param player the player + * @param scan false to record the position without triggering a scan - used + * on login, so a player who logged out inside a port is not searched + * the moment they take a step + */ + public void updatePosition(Player player, boolean scan) { if (!addon.inWorld(player.getWorld()) || addon.getOverWorld() == null || !player.getWorld().equals(addon.getOverWorld())) { insideIsland.remove(player.getUniqueId()); @@ -329,16 +393,37 @@ public void updatePosition(Player player) { } Optional here = islandSpaceAt(player); String was = insideIsland.get(player.getUniqueId()); - if (here.isEmpty()) { - insideIsland.remove(player.getUniqueId()); + String now = here.map(CustomsService::key).orElse(null); + if (java.util.Objects.equals(now, was)) { return; } - String now = key(here.get()); - if (now.equals(was)) { + // Leaving an island's space ends any chase it had running BEFORE the + // new island is considered. Warping out mid-chase used to leave the + // chase live, which then blocked the destination's scan entirely - the + // smuggler simply stopped being inspected anywhere. + if (was != null) { + leftIslandSpace(player, was); + } + if (now == null) { + insideIsland.remove(player.getUniqueId()); return; } insideIsland.put(player.getUniqueId(), now); - onEntry(player, here.get()); + if (scan) { + onEntry(player, here.get()); + } + } + + /** + * A player has left an island's space: if that island was chasing them, + * they got away. + */ + private void leftIslandSpace(Player player, String islandKey) { + Chase chase = chases.get(player.getUniqueId()); + if (chase != null && key(chase.island()).equals(islandKey)) { + escaped(player, chase); + end(player.getUniqueId(), chase); + } } /** diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java index ef2f794..efbbde0 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java @@ -62,11 +62,17 @@ public List dispatch(Player player, IslandSpec island) { if (toIsland.lengthSquared() < 0.01) { toIsland = new Vector(1, 0, 0); } - Location spot = from.clone().add(toIsland.normalize().multiply(INTERCEPT_DISTANCE)); - spot.setY(addon.getSettings().getSeaHeight() - 2.0); + Location spot = openWater(from.clone().add(toIsland.normalize().multiply(INTERCEPT_DISTANCE)), from); + if (spot == null) { + // Ashore in the market: there is no water to launch a patrol from, + // and a guardian spawned on a plaza just flops about. The caller + // turns this into a straight confiscation instead. + return units; + } for (int i = 0; i < count; i++) { - Location at = spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3); - Entity unit = spawn(at, i % 3 == 0 ? EntityType.GUARDIAN : EntityType.DROWNED, player); + Location at = openWater(spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3), from); + Entity unit = at == null ? null : spawn(at, i % 3 == 0 ? EntityType.GUARDIAN : EntityType.DROWNED, + player); if (unit != null) { units.add(unit); } @@ -74,6 +80,33 @@ public List dispatch(Player player, IslandSpec island) { return units; } + /** + * The nearest sea-level water column to a spot, searching outward, or null + * if there is none within reach. Police are sailors: they need somewhere to + * be. + * + * @param spot the preferred position + * @param fallbackToward searched around as well, so a patrol still launches + * when the ideal intercept point happens to be inland + * @return a water location, or null + */ + private Location openWater(Location spot, Location fallbackToward) { + int seaY = addon.getSettings().getSeaHeight(); + for (Location candidate : new Location[] { spot, fallbackToward }) { + for (int radius = 0; radius <= 24; radius += 4) { + for (int attempt = 0; attempt < 8; attempt++) { + double angle = attempt * Math.PI / 4; + Location at = candidate.clone().add(Math.cos(angle) * radius, 0, Math.sin(angle) * radius); + at.setY(seaY - 1.0); + if (at.getBlock().getType() == org.bukkit.Material.WATER) { + return at; + } + } + } + } + return null; + } + /** * How many units a band's customs office can field. Safe space answers * hard; out in the lawless bands there is nobody to send - which is the diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 3e3b731..1006302 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -59,6 +59,9 @@ permissions: tradewinds.admin.tpisland: description: Allow teleporting to trading islands default: op + tradewinds.admin.customs: + description: Show the customs state at your position (contraband, scan odds, patrol) + default: op tradewinds.admin.reflag: description: Allow re-applying security-band flags to all islands default: op diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index df13ede..1ac99f7 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -42,6 +42,19 @@ tradewinds: parameters: "[number]" unknown: "No island with that number - run /twadmin islands first." teleported: "Arrived at [name]." + customs: + description: "show what customs make of you here: contraband aboard, scan odds, patrol" + header: "Customs report:" + gate: "Crime layer enabled: [value]" + contraband: "Contraband list: [value]" + aboard: "Contraband in your hold: [number]" + standing: "Your standing: [value]" + open-sea: "Open sea - no island space here, so nothing to scan you." + island: "Island space: [name] ([band])" + scan-chance: "Scan chance on entry: [value]" + buys: "This port buys contraband: [value]" + patrol: "Patrol it can field: [number]" + chased: "Chase in progress: [value]" reflag: description: "re-apply security-band flags to all trading islands" done: "Re-applied band flags to [number] trading islands." @@ -55,7 +68,7 @@ tradewinds: offender: "Offender" wanted: "Wanted" fugitive: "Fugitive" - changed: "[title]Your standing:[subtitle][standing]" + changed: "[title]Your standing[subtitle][standing]" crime: hurt-villager: "The market saw that. Reputation -[number]." kill-villager: "A trader is dead. Reputation -[number], and there is money on your head." @@ -72,7 +85,9 @@ tradewinds: fine-barred: "'We don't take money from your sort.' Try a rougher port." customs: clean: "Customs board you, look through the hold, and wave you on." - detected: "[title]CUSTOMS[subtitle]Patrol dispatched from [name]" + detected: "[title]CUSTOMS[subtitle]Patrol dispatched from [name]" + detected-chat: "[name] customs inspect your hold: [number] items of contraband. A patrol is launched - run, fight, or dump the cargo." + seized: "You are ashore in the market with contraband. Customs simply take it: [number] items seized, [amount] in fines." flagged: "[name] remembers you. No inspection this time - the patrol is already coming." jettisoned: "Your cargo goes over the side. The patrol turns back - nothing to find." escaped: "Open water. [name] loses you - but its harbourmaster will not forget." diff --git a/src/test/java/world/bentobox/tradewinds/crime/CustomsLocaleMarkerTest.java b/src/test/java/world/bentobox/tradewinds/crime/CustomsLocaleMarkerTest.java new file mode 100644 index 0000000..01bac30 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/crime/CustomsLocaleMarkerTest.java @@ -0,0 +1,82 @@ +package world.bentobox.tradewinds.crime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Guards the locale's BentoBox routing markers. + *

+ * Playtest: a customs alert rendered as "CUSTOMS" followed by a literal + * line-feed glyph. BentoBox splits a [title]/[subtitle] string on the raw + * marker text and hands each half to MiniMessage separately - so a + * {@code } inside the title half is parsed into a real newline + * character in the title component, which a title cannot render. Titles are + * one line by definition; the subtitle is the second line. + * + * @author tastybento + */ +class CustomsLocaleMarkerTest { + + @SuppressWarnings("unchecked") + private Map locale() { + try (InputStream in = getClass().getClassLoader().getResourceAsStream("locales/en-US.yml")) { + return new Yaml().loadAs(in, LinkedHashMap.class); + } catch (Exception e) { + throw new IllegalStateException("Could not read en-US.yml", e); + } + } + + /** + * Walk every leaf string in the locale. + */ + @SuppressWarnings("unchecked") + private void walk(Map node, String path, java.util.function.BiConsumer leaf) { + node.forEach((key, value) -> { + String here = path.isEmpty() ? String.valueOf(key) : path + "." + key; + if (value instanceof Map child) { + walk((Map) child, here, leaf); + } else if (value != null) { + leaf.accept(here, String.valueOf(value)); + } + }); + } + + @Test + void testNoNewlinesInsideTitles() { + walk(locale(), "", (path, text) -> { + if (!text.toLowerCase(java.util.Locale.ENGLISH).startsWith("[title]")) { + return; + } + String titleHalf = text.split("(?i)\\[subtitle]", 2)[0]; + assertFalse(titleHalf.contains("") || titleHalf.contains("\\n"), + path + " puts a newline inside a title - it renders as a line-feed glyph. " + + "Use [subtitle] for the second line."); + }); + } + + @Test + void testRoutingMarkersAreAtTheStart() { + // BentoBox anchors [title] and [actionbar] to the start of the string; + // one buried mid-message is simply printed to chat as text + walk(locale(), "", (path, text) -> { + String lower = text.toLowerCase(java.util.Locale.ENGLISH); + if (lower.contains("[title]")) { + assertTrue(lower.startsWith("[title]"), path + " has [title] but not at the start"); + } + if (lower.contains("[actionbar]")) { + assertTrue(lower.startsWith("[actionbar]"), path + " has [actionbar] but not at the start"); + } + // A subtitle with no title is silently dropped + if (lower.contains("[subtitle]")) { + assertTrue(lower.startsWith("[title]"), path + " has [subtitle] without a [title]"); + } + }); + } +} From e1210860b4aa7c4fc6370dbd806895fc7fc33436 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 31 Jul 2026 23:52:57 -0700 Subject: [PATCH 054/112] Make contraband pay, and make the chase runnable Playtest: "sugar trading should be high risk, high reward - but the trader only offered ~$1 for it." A design hole, not a tuning nit: contraband was priced from its crafting recipe like every other good, and sugar's recipe price is about one unit. The risk was built and the reward never was. Contraband now carries a black-market premium (default x8) and counts as demanded at any port that deals in it, so the band bonus applies and running further out pays more: $11.90 at FRONTIER to $14.28 at ANARCHIC, against an honest margin of $6.38 per item on a mid-value good. Roughly double the return per hold slot with no capital outlay, which is what high risk / high reward has to mean. Traders no longer stock contraband either - otherwise a player buys it over the counter at the honest price and sells it back at the premium, with no farming and no risk. Two more from the same report. /twadmin customs said the port does not buy contraband while the sell page was quoting a price for it: the report was right and the dialog was wrong, so the sell page now omits goods the port will refuse. And "I got instantly hurt and lost the sugar" was the patrol water search falling back to the player's own position with no minimum distance - patrols spawned alongside the boat and the arrest registered within a tick. Patrols now keep a standoff of three times the arrest radius. 190 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 21 +++++++ docs/PROGRESS.md | 27 ++++++++- .../world/bentobox/tradewinds/Settings.java | 14 +++++ .../tradewinds/crime/PoliceDispatch.java | 57 ++++++++++++++----- .../tradewinds/economy/MarketService.java | 43 ++++++++++++-- .../tradewinds/economy/TradeDialog.java | 7 +++ src/main/resources/config.yml | 9 +++ .../tradewinds/crime/ContrabandTest.java | 40 +++++++++++++ 8 files changed, 197 insertions(+), 21 deletions(-) diff --git a/TESTING.md b/TESTING.md index f8386ad..c590646 100644 --- a/TESTING.md +++ b/TESTING.md @@ -669,6 +669,27 @@ cooldowns are invisible, so guessing from behaviour alone is a trap. patrol to launch from, so customs simply seize the cargo and fine you, with a message saying so — rather than spawning guardians that flop about on land. +### 6b-0b — Fixes from the second playtest +- [ ] **Contraband actually pays.** Sell sugar at a FRONTIER/LAWLESS/ANARCHIC port: + roughly $12-14 an item, not $1. It pays more the rougher the port. + (`illegal-trade.contraband-price-multiplier`, default 8.0 — the main balance + lever, and the one price in the game not funded by a purchase elsewhere.) +- [ ] The sell page **no longer quotes a price for contraband at a port that will + refuse it** — sugar simply is not listed at SAFE/POLICED islands, instead of + being offered for a dollar and then declined at the counter. This is what made + `/twadmin customs` look like it was lying: it said "buys contraband: false" + while the dialog was showing an offer. +- [ ] **No trader stocks contraband.** Check the buy page at an AGRICULTURAL island: + no sugar. (If they sold it you could buy at the honest price and sell at the + black-market premium with no farming and no risk.) +- [ ] **A patrol never spawns on top of you.** It surfaces at least ~12 blocks away, + well beyond the 4-block arrest radius, so there is always a chase to run. + Previously the water search fell back to the player's own position: patrols + materialised alongside the boat, opened fire, and the arrest registered before + the warning had been read — "I got instantly hurt and lost the sugar". +- [ ] Note that warp arrival damage (`travel.warp.damage`, default 2.0) is separate + and intended — warping hurts. Do not confuse it with a patrol. + ### 6b-i — The scan - [ ] Row or warp into a **SAFE** island's protection range carrying no contraband: "Customs board you, look through the hold, and wave you on." (Set diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9915119..1d0409b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -64,10 +64,35 @@ radius, 400-block break-off, 120-second chase cap. straight confiscation, which is what being caught ashore in a market should mean anyway. +**Second playtest: the reward half was missing, and the chase was unrunnable.** +- "Sugar trading should be high risk, high reward - but the trader only offered + ~$1 for it." Correct, and a design hole rather than a tuning nit: contraband + was priced from its crafting recipe like every other good, and sugar's recipe + price is about one currency unit. The risk was built; the reward never was. + Contraband now carries a **black-market premium** + (`illegal-trade.contraband-price-multiplier`, default 8.0) and counts as + *demanded* at any port that deals in it, so the existing band bonus applies + and running further out pays more. Measured: $11.90 at FRONTIER rising to + $14.28 at ANARCHIC, against an honest margin of $6.38 per item on a mid-value + good - roughly double the return per hold slot, with no capital outlay, which + is what "high risk, high reward" has to mean. A test pins the shape. +- Traders no longer **stock** contraband. If they did, a player could buy it + over the counter at the honest price and sell it back at the premium with no + farming and no risk - a money printer hiding behind the new premium. +- "`/twadmin customs` reports that the port does not buy it, but they did." The + report was right and the dialog was wrong: the sell page quoted a price for + contraband at ports that would refuse it at the counter. It now omits them. +- "When I arrived, I got instantly hurt and lost the sugar. No idea what + happened." The water search for a patrol spawn fell back to the player's own + position with no minimum distance, so patrols materialised alongside the boat, + opened fire, and the chase tick registered an arrest within one tick. Patrols + now keep a standoff of at least three times the arrest radius. A chase you + cannot run from is not a chase. + Also added `/twadmin customs`: contraband aboard, standing, band, effective scan chance, patrol size, chase state. Scans are probabilistic and cooldowns are invisible, so "is it working?" was not answerable from behaviour alone - that -was the real complaint, and a diagnostic is the answer to it. 189 tests green. +was the real complaint, and a diagnostic is the answer to it. 190 tests green. ## Stage 6a — the law: port flags, reputation, fines (2026-07-31) diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index cd54a63..43b9bf5 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -622,6 +622,18 @@ private static Map defaultEncounterChance() { @ConfigEntry(path = "illegal-trade.safest-contraband-buyer") private String safestContrabandBuyer = "FRONTIER"; + @ConfigComment("What a black market pays for contraband, as a multiplier on its ordinary price.") + @ConfigComment("This is the reward half of high-risk/high-reward, and the main lever on how") + @ConfigComment("profitable smuggling is. Contraband is priced from its crafting recipe like") + @ConfigComment("everything else, and sugar's recipe price is about one unit - so without this") + @ConfigComment("a smuggler runs a customs patrol and is offered a dollar for the cargo.") + @ConfigComment("Ports that deal in it always want it, so the band demand bonus applies too:") + @ConfigComment("the rougher the port, the better it pays. Raise with care - contraband is") + @ConfigComment("farmable, and this is the one price in the game that is not paid for by a") + @ConfigComment("purchase somewhere else.") + @ConfigEntry(path = "illegal-trade.contraband-price-multiplier") + private double contrabandPriceMultiplier = 8.0; + @ConfigComment("Chance (0-1) that entering an island's space triggers a customs scan, per band.") @ConfigComment("Safe space searches everyone; out in the lawless bands nobody is looking.") @ConfigEntry(path = "illegal-trade.scan-chance") @@ -2848,6 +2860,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setBootyTable(List bootyTable) { this.bootyTable = bootyTable; } public String getSafestContrabandBuyer() { return safestContrabandBuyer; } public void setSafestContrabandBuyer(String v) { this.safestContrabandBuyer = v; } + public double getContrabandPriceMultiplier() { return contrabandPriceMultiplier; } + public void setContrabandPriceMultiplier(double v) { this.contrabandPriceMultiplier = v; } public Map getScanChance() { return scanChance; } public void setScanChance(Map v) { this.scanChance = v; } public double getScanUpstandingFactor() { return scanUpstandingFactor; } diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java index efbbde0..d9170db 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java @@ -62,7 +62,8 @@ public List dispatch(Player player, IslandSpec island) { if (toIsland.lengthSquared() < 0.01) { toIsland = new Vector(1, 0, 0); } - Location spot = openWater(from.clone().add(toIsland.normalize().multiply(INTERCEPT_DISTANCE)), from); + Location spot = openWater(from.clone().add(toIsland.normalize().multiply(INTERCEPT_DISTANCE)), from, + minimumStandoff()); if (spot == null) { // Ashore in the market: there is no water to launch a patrol from, // and a guardian spawned on a plaza just flops about. The caller @@ -70,7 +71,8 @@ public List dispatch(Player player, IslandSpec island) { return units; } for (int i = 0; i < count; i++) { - Location at = openWater(spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3), from); + Location at = openWater(spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3), from, + minimumStandoff()); Entity unit = at == null ? null : spawn(at, i % 3 == 0 ? EntityType.GUARDIAN : EntityType.DROWNED, player); if (unit != null) { @@ -80,27 +82,52 @@ public List dispatch(Player player, IslandSpec island) { return units; } + /** + * How close a patrol may surface to its quarry. This must stay comfortably + * beyond the arrest radius: the first cut let the water search fall back to + * the player's own position, so patrols materialised alongside the boat, + * opened fire, and the chase tick registered an arrest before the player + * had read the warning. A chase you cannot run from is not a chase. + * + * @return the minimum spawn distance in blocks + */ + private double minimumStandoff() { + return Math.max(12.0, addon.getSettings().getCaughtRadius() * 3); + } + /** * The nearest sea-level water column to a spot, searching outward, or null * if there is none within reach. Police are sailors: they need somewhere to - * be. + * be - but never within {@code standoff} blocks of the player. * * @param spot the preferred position - * @param fallbackToward searched around as well, so a patrol still launches - * when the ideal intercept point happens to be inland + * @param player where the quarry is, which the patrol must not spawn on top of + * @param standoff minimum distance from the player * @return a water location, or null */ - private Location openWater(Location spot, Location fallbackToward) { + private Location openWater(Location spot, Location player, double standoff) { int seaY = addon.getSettings().getSeaHeight(); - for (Location candidate : new Location[] { spot, fallbackToward }) { - for (int radius = 0; radius <= 24; radius += 4) { - for (int attempt = 0; attempt < 8; attempt++) { - double angle = attempt * Math.PI / 4; - Location at = candidate.clone().add(Math.cos(angle) * radius, 0, Math.sin(angle) * radius); - at.setY(seaY - 1.0); - if (at.getBlock().getType() == org.bukkit.Material.WATER) { - return at; - } + double standoffSquared = standoff * standoff; + // Widen the ring around the intended spot until water turns up, then + // fall back to a ring around the player at the standoff distance + for (int radius = 0; radius <= 32; radius += 4) { + for (int attempt = 0; attempt < 12; attempt++) { + double angle = attempt * Math.PI / 6; + Location at = spot.clone().add(Math.cos(angle) * radius, 0, Math.sin(angle) * radius); + at.setY(seaY - 1.0); + if (at.distanceSquared(player) >= standoffSquared + && at.getBlock().getType() == org.bukkit.Material.WATER) { + return at; + } + } + } + for (int radius = (int) standoff; radius <= standoff + 32; radius += 4) { + for (int attempt = 0; attempt < 12; attempt++) { + double angle = attempt * Math.PI / 6; + Location at = player.clone().add(Math.cos(angle) * radius, 0, Math.sin(angle) * radius); + at.setY(seaY - 1.0); + if (at.getBlock().getType() == org.bukkit.Material.WATER) { + return at; } } } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 9b7cdd5..5052880 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -53,7 +53,12 @@ public PriceEngine getPriceEngine() { * What this island trades: its type's produce catalog. */ public java.util.List saleCatalog(IslandSpec spec) { - return TypeEconomy.catalog(spec.type()); + // Traders never stock contraband. If they did, a smuggler could buy it + // over the counter at the honest price and sell it back at the black + // market premium with no farming and no risk at all. + return TypeEconomy.catalog(spec.type()).stream() + .filter(m -> addon.getCustomsService() == null || !addon.getCustomsService().isContraband(m)) + .toList(); } /** @@ -189,20 +194,48 @@ public Optional basePrice(Material material) { * What a player pays this island per unit, or empty if not tradeable. */ public Optional playerBuysAt(IslandSpec spec, Material material) { - return basePrice(material).map(base -> model().playerBuysAt(base, factor(spec, material))); + return basePrice(material) + .map(base -> model().playerBuysAt(base * contrabandPremium(material), factor(spec, material))); } /** * What this island pays a player per unit, or empty if not tradeable. */ public Optional playerSellsAt(IslandSpec spec, Material material) { - return basePrice(material).map(base -> model().playerSellsAt(base, factor(spec, material))); + return basePrice(material) + .map(base -> model().playerSellsAt(base * contrabandPremium(material), factor(spec, material))); + } + + /** + * The smuggler's premium. + *

+ * Contraband is priced from its crafting recipe like everything else, and + * sugar's recipe price is about one currency unit - so the first playtest + * ran cargo past a customs patrol and was offered a dollar for it. The risk + * was built and the reward was not. This is the reward: what a black market + * pays over the honest price of the same goods, and the main lever for how + * profitable smuggling is. + * + * @param material the material + * @return the multiplier, 1.0 for anything legal + */ + public double contrabandPremium(Material material) { + if (addon.getCustomsService() == null || !addon.getCustomsService().isContraband(material)) { + return 1.0; + } + return Math.max(1.0, addon.getSettings().getContrabandPriceMultiplier()); } private double factor(IslandSpec spec, Material material) { TradeCategory category = TradeCategory.of(material); - return model().economicFactor(TypeEconomy.produces(spec.type()).contains(category), - TypeEconomy.demands(spec.type()).contains(category), spec.band().ordinal(), + boolean contraband = addon.getCustomsService() != null + && addon.getCustomsService().isContraband(material); + // A port that deals in contraband always wants it - it is not on + // anybody's official produce list, and demand is what makes the band + // bonus apply, so the rougher the port the better it pays + boolean demands = contraband || TypeEconomy.demands(spec.type()).contains(category); + boolean produces = !contraband && TypeEconomy.produces(spec.type()).contains(category); + return model().economicFactor(produces, demands, spec.band().ordinal(), addon.getIslandDataManager().getStock(spec, category)); } diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 223dbb4..0695910 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -243,6 +243,13 @@ List sellOffers(Player player, IslandSpec spec) { // Traders only buy customs-stamped goods (plus the illegal exceptions) for (Map.Entry entry : addon.getHoldService() .contents(player, addon.getMarketService().sellableFilter()).entrySet()) { + // Do not quote a price for something this port will refuse at the + // counter: the sell page used to offer a dollar for contraband that + // a safe island would then decline, which reads as a broken market + if (addon.getCustomsService() != null && addon.getCustomsService().isContraband(entry.getKey()) + && !addon.getCustomsService().buysContraband(spec.band())) { + continue; + } addon.getMarketService().playerSellsAt(spec, entry.getKey()) .ifPresent(unit -> offers.add(new SellOffer(entry.getKey(), entry.getValue(), unit, PriceModel.round2(unit * entry.getValue())))); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 17ce290..0db0117 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -328,6 +328,15 @@ illegal-trade: # what pushes smuggling runs outward - crime pays, into danger. # SAFE, POLICED, FRONTIER, LAWLESS or ANARCHIC. safest-contraband-buyer: FRONTIER + # What a black market pays for contraband, as a multiplier on its ordinary price. + # This is the reward half of high-risk/high-reward and the main lever on how + # profitable smuggling is. Contraband is priced from its crafting recipe like + # everything else, and sugar's recipe price is about one unit - so without this a + # smuggler runs a customs patrol and is offered a dollar for the cargo. + # Ports that deal in it always want it, so the band demand bonus applies too: the + # rougher the port, the better it pays. Raise with care - contraband is farmable, + # and this is the one price in the game not paid for by a purchase somewhere else. + contraband-price-multiplier: 8.0 # Chance (0-1) that entering an island's space triggers a customs scan, per band. # Safe space searches everyone; out in the lawless bands nobody is looking. Note # these are deliberately the inverse of who will BUY contraband: nowhere both diff --git a/src/test/java/world/bentobox/tradewinds/crime/ContrabandTest.java b/src/test/java/world/bentobox/tradewinds/crime/ContrabandTest.java index 97e993d..ea2f456 100644 --- a/src/test/java/world/bentobox/tradewinds/crime/ContrabandTest.java +++ b/src/test/java/world/bentobox/tradewinds/crime/ContrabandTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; +import world.bentobox.tradewinds.economy.PriceModel; import world.bentobox.tradewinds.galaxy.SecurityBand; /** @@ -63,6 +64,45 @@ void testTheBandsThatBuyAreTheBandsThatDoNotScan() { } } + @Test + void testContrabandActuallyPaysForTheRisk() { + // Playtest: "sugar trading should be high risk, high reward - but the + // trader only offered ~$1 for it". Contraband is priced from its + // crafting recipe like everything else, and sugar's recipe price is + // about one unit, so the risk was built and the reward was not. + PriceModel model = new PriceModel(0.6, 1.4, 0.125, 1.15, 0.85, 500, 0.7, 1.3); + double premium = 8.0; + double sugarBase = 1.0; + + // A port that deals in contraband always wants it, so the band bonus + // applies: the rougher the port, the better it pays + double frontier = model.playerSellsAt(sugarBase * premium, + model.economicFactor(false, true, SecurityBand.FRONTIER.ordinal(), 0)); + double lawless = model.playerSellsAt(sugarBase * premium, + model.economicFactor(false, true, SecurityBand.LAWLESS.ordinal(), 0)); + double anarchic = model.playerSellsAt(sugarBase * premium, + model.economicFactor(false, true, SecurityBand.ANARCHIC.ordinal(), 0)); + assertTrue(lawless > frontier, "Running further out must pay more"); + assertTrue(anarchic > lawless, "Running further out must pay more"); + + // ... and it must beat honest trading per unit of hold space, or there + // is no reason to take the risk at all. Compare against the margin on a + // mid-value good bought where it is produced and sold where it is wanted. + double honestBase = 8.0; + double honestMargin = model.playerSellsAt(honestBase, + model.economicFactor(false, true, SecurityBand.FRONTIER.ordinal(), 0)) + - model.playerBuysAt(honestBase, + model.economicFactor(true, false, SecurityBand.FRONTIER.ordinal(), 0)); + assertTrue(frontier > honestMargin, + "Contraband pays " + frontier + "/item against an honest margin of " + honestMargin); + + // Without the premium it is worth less than the honest margin, which is + // exactly the bug that was reported + double unpriced = model.playerSellsAt(sugarBase, + model.economicFactor(false, true, SecurityBand.FRONTIER.ordinal(), 0)); + assertTrue(unpriced < honestMargin); + } + @Test void testStandingMovesTheOddsBothWays() { // A clean name is worth something at the border (spec 7: positive rep From 1f0c4da9de25df4fb540e2fe14afcd85d686ab21 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 07:33:42 -0700 Subject: [PATCH 055/112] Stage 6c: police response, PvP override, visible bounties Closes Stage 6. While you are inside a policed island's waters and wanted, the law comes. PoliceRoster holds the decision and is pure, so the shape is testable without a server. Units are picked by mobility because each denies a different escape: golems take anyone ashore, guardians hold the water, and phantoms are the only thing that can follow a boat - a sea response without one is a response you simply row away from. Police break off ~400 blocks past the border. Without that an escort would follow a player across the ocean and the security bands would flatten into one difficulty everywhere; ANARCHIC sends nobody at all, which is the entire reason to run out there. Units are recalled on break-off, logout, world change, paying a fine, and ceasing to be wanted, and are non-persistent so an unloaded chunk takes them too. Phantoms do not burn at dawn, and a target guard keeps patrols off bystanders. BentoBox cancels player damage at LOW priority, so the PvP override un-cancels at NORMAL when the victim is a lawful target - otherwise a wanted player could moor in a safe band and be untouchable, and bounty hunting would only work where it was least needed. Bounties are visible via a scoreboard team suffix, config-gated because it fights TAB-style plugins, with PlaceholderAPI placeholders alongside. The fugitive trade bar was specified in 6a but never enforced - the method existed and nothing called it. Safe ports now refuse to open the market. 197 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 47 ++++ docs/PROGRESS.md | 52 ++++ .../world/bentobox/tradewinds/Settings.java | 32 +++ .../world/bentobox/tradewinds/TradeWinds.java | 24 ++ .../tradewinds/crime/BountyBoard.java | 132 ++++++++++ .../tradewinds/crime/PoliceDispatch.java | 75 +++++- .../tradewinds/crime/PoliceRoster.java | 92 +++++++ .../tradewinds/crime/PoliceService.java | 249 ++++++++++++++++++ .../bentobox/tradewinds/crime/PoliceUnit.java | 24 ++ .../tradewinds/economy/MarketService.java | 20 ++ .../tradewinds/economy/TradeDialog.java | 5 + src/main/resources/config.yml | 9 + src/main/resources/locales/en-US.yml | 5 + .../tradewinds/crime/PoliceRosterTest.java | 95 +++++++ 14 files changed, 858 insertions(+), 3 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/crime/BountyBoard.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/PoliceRoster.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/PoliceService.java create mode 100644 src/main/java/world/bentobox/tradewinds/crime/PoliceUnit.java create mode 100644 src/test/java/world/bentobox/tradewinds/crime/PoliceRosterTest.java diff --git a/TESTING.md b/TESTING.md index c590646..09d9304 100644 --- a/TESTING.md +++ b/TESTING.md @@ -734,3 +734,50 @@ cooldowns are invisible, so guessing from behaviour alone is a trap. `ANARCHIC` makes it almost unsellable. - [ ] `illegal-trade.enabled: false` removes all of it: no scans, no patrols, and sugar becomes an ordinary unsellable homemade good. + +## Stage 6c — Police, wanted response, bounties + +Get yourself **Wanted** (−200: one murder, or ~8 villager kills) with +`/twadmin customs` to check your standing, then sail into a policed island's space. + +### 6c-i — The standing response +- [ ] Enter a **SAFE** or **POLICED** island's protection range while Wanted: "…has + patrols out for you", and units appear — guardians, drowned and a **phantom** + if you are afloat; **iron golems** and a phantom if you are ashore. +- [ ] The phantom is the only thing that can follow a boat. Row away and confirm it + keeps up while the swimmers fall behind. That is the point of the roster. +- [ ] Phantoms **do not burn at dawn**. Trigger a response in daylight and watch. +- [ ] A **FUGITIVE** draws a larger response than merely Wanted. +- [ ] **LAWLESS** sends one unit; **ANARCHIC** sends nobody at all. Confirm you can + sit in anarchic water as a fugitive completely unmolested — that is the whole + reason to go out there. +- [ ] Police ignore innocent bystanders: stand a second (clean) player next to the + patrol and confirm it stays on the wanted one. + +### 6c-ii — Break-off and no leaks +- [ ] Cross the border and keep going: past ~400 blocks beyond the protection range + the patrol breaks off ("The patrol turns back at the border") and the units + are **removed**, not left drifting. +- [ ] Pay your fine mid-pursuit (`/tw fine`): the patrol stands down immediately. +- [ ] Log out mid-pursuit, log back in: no orphaned police anywhere. +- [ ] Fly a long way off and come back: no accumulation of stray guardians or + phantoms. (`/twadmin customs` shows whether a chase is live.) +- [ ] Kill police: still **no drops, no XP**, whatever the unit type. + +### 6c-iii — PvP override and bounties +- [ ] A Wanted player can be attacked **even on a SAFE island** where the PvP flag is + off. A clean player in the same place still cannot be attacked. (Without this a + wanted player could moor in high security and be untouchable.) +- [ ] Killing them pays their bounty to the killer, with no reputation penalty. +- [ ] The bounty is cleared by the payout — kill them again and it pays **nothing**. +- [ ] Their name shows the bounty beside it while it is above zero, and the tag + disappears once paid out. (`crime.bounty-nameplate: false` removes it.) +- [ ] With PlaceholderAPI installed, `%tradewinds_bounty%`, `%tradewinds_standing%`, + `%tradewinds_reputation%` and `%tradewinds_wanted%` all resolve. Servers using + TAB should turn the nameplate off and use these instead. + +### 6c-iv — Fugitive trade bar +- [ ] As a **Fugitive**, the market at a SAFE or POLICED island refuses to open at + all ("We know what you are"). +- [ ] At FRONTIER and beyond it opens normally. Merely **Wanted** is not barred — + only Fugitive. (`crime.safest-fugitive-trader`.) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 1d0409b..bb4ecba 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,58 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 6c — police, wanted response, bounties (2026-08-01) + +Closes Stage 6. The law now answers for itself: while you are inside a policed +island's waters and wanted, patrols come. + +`PoliceRoster` holds the decision and is pure, so the shape is testable without +a server. Units are picked by **mobility**, because each denies a different +escape (spec section 7): golems take anyone ashore, guardians hold the water, +and **phantoms are the only thing that can follow a boat**. A sea response +without a phantom is a response you simply row away from - there is a test +asserting every roster can pursue. + +Three properties keep this from becoming a nuisance or a farm: +- **Break off at the border.** Police give up ~400 blocks past the protection + range. Without it an escort would follow a player across the ocean and the + security bands would flatten into one difficulty everywhere - lawless water + has to be genuinely where the law is not. ANARCHIC sends nobody at all, which + is the entire reason to run out there. +- **No leaks.** Units are recalled on break-off, on logout, on world change, on + paying a fine, and on ceasing to be wanted; they are non-persistent, so an + unloaded chunk takes them too. +- **No drops.** Already enforced for every tagged unit since 6b. + +Police phantoms do not burn at dawn - a pursuit that ends because the sun came +up is not a pursuit - and an `EntityTargetEvent` guard keeps patrols off +innocent bystanders. + +**PvP override**: BentoBox's own PvP listener cancels player damage at LOW +priority, so the override un-cancels it at NORMAL when the victim is a lawful +target. Without it a wanted player could moor in a SAFE band and be +untouchable, and bounty hunting would only work where it was least needed. + +**Bounties are visible now**: a scoreboard team suffix beside the name while the +bounty is above zero. A bounty nobody can see is not a bounty. Because that +fights TAB-style nametag plugins, it is config-gated and PlaceholderAPI +placeholders are exposed alongside (`bounty`, `bounty_raw`, `standing`, +`reputation`, `wanted`) so a server can render it wherever it already owns the +name. + +**Fugitive trade bar** was specified in 6a (`Standing.isBarredFromSafeTrade`) +but never actually enforced at a market - the method existed and nothing called +it. Now the dialog refuses to open at safe ports for a fugitive, which is +principle 4 arrived at from the other direction: smuggling pushes you outward, +and so does burning your name. + +**Pitfall:** a `str.replace` on the locale for `" customs:"` also matched the +six-space-indented admin `customs:` block and corrupted the YAML. Anchor +replacements on a newline plus the full indent, and re-parse the file +afterwards - which is how it was caught. + +197 tests green. + ## Stage 6b — customs and contraband (2026-07-31) The scan on entering island space, and the chase after it. Detection is diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 43b9bf5..f83a3cd 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -248,6 +248,19 @@ private static Map defaultBandHurtVillagers() { @ConfigEntry(path = "crime.fine-per-point") private double finePerPoint = 2.0; + @ConfigComment("The safest band that will still trade with a FUGITIVE. Safer ports refuse them") + @ConfigComment("outright: having burned your name, the only markets left are where the law is") + @ConfigComment("thin. One of SAFE, POLICED, FRONTIER, LAWLESS, ANARCHIC.") + @ConfigEntry(path = "crime.safest-fugitive-trader") + private String safestFugitiveTrader = "FRONTIER"; + + @ConfigComment("Show a player's bounty beside their name, via a scoreboard team suffix.") + @ConfigComment("A bounty nobody can see is not a bounty - a hunter has to be able to tell at a") + @ConfigComment("glance. Turn this OFF if the server runs TAB or another nametag plugin (they") + @ConfigComment("will fight over the team) and render %tradewinds_bounty% there instead.") + @ConfigEntry(path = "crime.bounty-nameplate") + private boolean bountyNameplate = true; + @ConfigComment("Reputation lost per crime (negative numbers).") @ConfigEntry(path = "crime.penalties") private Map crimePenalties = defaultCrimePenalties(); @@ -2764,6 +2777,25 @@ public void setConcurrentIslands(int concurrentIslands) { public void setReputationDecayMinutes(int v) { this.reputationDecayMinutes = v; } public int getReputationDecayPoints() { return reputationDecayPoints; } public void setReputationDecayPoints(int v) { this.reputationDecayPoints = v; } + public String getSafestFugitiveTrader() { return safestFugitiveTrader; } + public void setSafestFugitiveTrader(String v) { this.safestFugitiveTrader = v; } + public boolean isBountyNameplate() { return bountyNameplate; } + public void setBountyNameplate(boolean v) { this.bountyNameplate = v; } + + /** + * The safest band that will still trade with a fugitive. + * + * @return the band + */ + public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { + try { + return world.bentobox.tradewinds.galaxy.SecurityBand + .valueOf(safestFugitiveTrader.toUpperCase(java.util.Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + return world.bentobox.tradewinds.galaxy.SecurityBand.FRONTIER; + } + } + public double getFinePerPoint() { return finePerPoint; } public void setFinePerPoint(double finePerPoint) { this.finePerPoint = finePerPoint; } public Map getCrimePenalties() { return crimePenalties; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 2238fb9..e6da133 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -36,9 +36,11 @@ import world.bentobox.tradewinds.commands.TWStarChartCommand; import world.bentobox.tradewinds.commands.TWTradeCommand; import world.bentobox.tradewinds.commands.TWWarpCommand; +import world.bentobox.tradewinds.crime.BountyBoard; import world.bentobox.tradewinds.crime.CrimeListener; import world.bentobox.tradewinds.crime.CustomsListener; import world.bentobox.tradewinds.crime.CustomsService; +import world.bentobox.tradewinds.crime.PoliceService; import world.bentobox.tradewinds.crime.ReputationService; import world.bentobox.tradewinds.crime.Standing; import world.bentobox.tradewinds.dataobjects.IslandDataManager; @@ -108,6 +110,8 @@ public class TradeWinds extends GameModeAddon { private ReputationService reputationService; private CrimeListener crimeListener; private CustomsService customsService; + private PoliceService policeService; + private BountyBoard bountyBoard; private NamespacedKey policeKey; private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; @@ -271,6 +275,12 @@ public void onEnable() { customsService = new CustomsService(this); customsService.start(); registerListener(new CustomsListener(this)); + // The standing response to a wanted player, and what they are worth + policeService = new PoliceService(this); + policeService.start(); + registerListener(policeService); + bountyBoard = new BountyBoard(this); + bountyBoard.start(); // Residents survive the night: no mob targeting, tether, respawn registerListener(new ResidentProtectionListener()); residentAuditTask = new ResidentAuditTask(this); @@ -303,6 +313,12 @@ public void onDisable() { if (customsService != null) { customsService.stop(); } + if (policeService != null) { + policeService.stop(); + } + if (bountyBoard != null) { + bountyBoard.stop(); + } if (chartHolograms != null) { chartHolograms.clearAll(); } @@ -518,6 +534,14 @@ public CustomsService getCustomsService() { return customsService; } + public PoliceService getPoliceService() { + return policeService; + } + + public BountyBoard getBountyBoard() { + return bountyBoard; + } + /** * The PDC key marking an entity as a police unit: dispatched by the law, * never drops loot, and killing one is a crime. diff --git a/src/main/java/world/bentobox/tradewinds/crime/BountyBoard.java b/src/main/java/world/bentobox/tradewinds/crime/BountyBoard.java new file mode 100644 index 0000000..6cb0ba4 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/BountyBoard.java @@ -0,0 +1,132 @@ +package world.bentobox.tradewinds.crime; + +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.Team; + +import net.kyori.adventure.text.Component; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Shows what a player is worth, above their head and to other plugins. + *

+ * A bounty nobody can see is not a bounty: bounty hunting only works if a + * hunter can tell at a glance that the person in the boat is worth money. + * Boats prevent sneaking, so a travelling pirate is always visible - that is a + * feature, not an oversight. + *

+ * Servers running TAB or a nametag plugin will fight a scoreboard team suffix, + * so this is config-gated and a PlaceholderAPI placeholder is exposed + * alongside: turn the nameplate off and render {@code %tradewinds_bounty%} + * however the server already renders names. + * + * @author tastybento + */ +public class BountyBoard { + + /** Scoreboard team names are length-limited, so the UUID is truncated. */ + private static final int TEAM_KEY_LENGTH = 12; + private static final String TEAM_PREFIX = "tw_b_"; + private static final long PERIOD = 100L; + + private final TradeWinds addon; + private BukkitTask task; + + public BountyBoard(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + registerPlaceholders(); + if (!addon.getSettings().isBountyNameplate()) { + return; + } + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::refresh, PERIOD, PERIOD); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + Bukkit.getOnlinePlayers().forEach(player -> clear(player.getUniqueId())); + } + + /** + * Update every online player's nameplate. + */ + void refresh() { + for (Player player : Bukkit.getOnlinePlayers()) { + if (!addon.inWorld(player.getWorld())) { + clear(player.getUniqueId()); + continue; + } + double bounty = addon.getReputationService().bounty(player.getUniqueId()); + if (bounty <= 0) { + clear(player.getUniqueId()); + } else { + show(player, bounty); + } + } + } + + private void show(Player player, double bounty) { + Scoreboard board = Bukkit.getScoreboardManager().getMainScoreboard(); + Team team = team(board, player.getUniqueId()); + // The suffix is one string for every viewer, so it takes the server's + // own locale rather than any one player's + Component suffix = User.getInstance(Bukkit.getConsoleSender()).getTranslationAsComponent( + "tradewinds.police.bounty-tag", "[amount]", format(bounty)); + team.suffix(suffix); + if (!team.hasEntry(player.getName())) { + team.addEntry(player.getName()); + } + } + + private Team team(Scoreboard board, UUID playerId) { + String name = TEAM_PREFIX + playerId.toString().substring(0, TEAM_KEY_LENGTH); + Team team = board.getTeam(name); + return team == null ? board.registerNewTeam(name) : team; + } + + /** + * Drop a player's nameplate - paid out, or never had one. + * + * @param playerId the player + */ + public void clear(UUID playerId) { + Scoreboard board = Bukkit.getScoreboardManager().getMainScoreboard(); + Team team = board.getTeam(TEAM_PREFIX + playerId.toString().substring(0, TEAM_KEY_LENGTH)); + if (team != null) { + team.unregister(); + } + } + + private String format(double amount) { + return addon.getPlugin().getVault().map(vault -> vault.format(amount)) + .orElse(String.format("%.0f", amount)); + } + + /** + * Expose the law's view of a player to PlaceholderAPI, so servers that + * already own the nametag can render it themselves instead of fighting the + * scoreboard team. + */ + private void registerPlaceholders() { + var placeholders = addon.getPlugin().getPlaceholdersManager(); + placeholders.registerPlaceholder(addon, "bounty", + user -> user == null ? "0" : format(addon.getReputationService().bounty(user.getUniqueId()))); + placeholders.registerPlaceholder(addon, "bounty_raw", user -> user == null ? "0" + : String.format("%.2f", addon.getReputationService().bounty(user.getUniqueId()))); + placeholders.registerPlaceholder(addon, "standing", + user -> user == null ? "" : addon.getPlayerStanding(user, user.getUniqueId())); + placeholders.registerPlaceholder(addon, "reputation", user -> user == null ? "0" + : String.valueOf(addon.getReputationService().score(user.getUniqueId()))); + placeholders.registerPlaceholder(addon, "wanted", user -> user == null ? "false" + : String.valueOf(addon.getReputationService().standing(user.getUniqueId()).isHunted())); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java index d9170db..e9300c7 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java @@ -70,11 +70,10 @@ public List dispatch(Player player, IslandSpec island) { // turns this into a straight confiscation instead. return units; } - for (int i = 0; i < count; i++) { + for (PoliceUnit kind : PoliceRoster.forCustoms(count)) { Location at = openWater(spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3), from, minimumStandoff()); - Entity unit = at == null ? null : spawn(at, i % 3 == 0 ? EntityType.GUARDIAN : EntityType.DROWNED, - player); + Entity unit = at == null ? null : spawn(at, entityType(kind), player); if (unit != null) { units.add(unit); } @@ -82,6 +81,76 @@ public List dispatch(Player player, IslandSpec island) { return units; } + /** + * Send the standing response to a wanted player: whatever the band can + * field, picked for where the target is standing. + * + * @param player the wanted player + * @param island the island whose law is responding + * @param ashore whether the target is on land + * @param fugitive whether they are a fugitive rather than merely wanted + * @return the units dispatched + */ + public List dispatchWanted(Player player, IslandSpec island, boolean ashore, boolean fugitive) { + List units = new ArrayList<>(); + Location from = player.getLocation(); + for (PoliceUnit kind : PoliceRoster.forWanted(island.band(), patrolSize(island.band()), ashore, + fugitive)) { + Location at = spawnPoint(kind, from); + Entity unit = at == null ? null : spawn(at, entityType(kind), player); + if (unit != null) { + units.add(unit); + } + } + return units; + } + + /** + * Where a unit of this kind can stand: golems need ground, swimmers need + * water, phantoms need only air - and none of them may appear inside the + * arrest radius. + */ + private Location spawnPoint(PoliceUnit kind, Location from) { + double standoff = minimumStandoff(); + return switch (kind) { + case PHANTOM -> from.clone().add(offset(standoff), 12 + Math.random() * 6, offset(standoff)); + case GOLEM -> ground(from, standoff); + default -> openWater(from.clone().add(offset(standoff * 1.5), 0, offset(standoff * 1.5)), from, standoff); + }; + } + + private static double offset(double standoff) { + double sign = Math.random() < 0.5 ? -1 : 1; + return sign * (standoff + Math.random() * standoff); + } + + /** + * A solid footing near the target for a golem, or null if there is none - + * a golem dropped into deep water is just a drowning golem. + */ + private Location ground(Location from, double standoff) { + for (int attempt = 0; attempt < 12; attempt++) { + double angle = attempt * Math.PI / 6; + double radius = standoff + Math.random() * standoff; + Location at = from.clone().add(Math.cos(angle) * radius, 0, Math.sin(angle) * radius); + at.setY(from.getWorld().getHighestBlockYAt(at) + 1.0); + if (at.getY() > addon.getSettings().getSeaHeight() + && at.getBlock().getType() == org.bukkit.Material.AIR) { + return at; + } + } + return null; + } + + private static EntityType entityType(PoliceUnit unit) { + return switch (unit) { + case GOLEM -> EntityType.IRON_GOLEM; + case GUARDIAN -> EntityType.GUARDIAN; + case DROWNED -> EntityType.DROWNED; + case PHANTOM -> EntityType.PHANTOM; + }; + } + /** * How close a patrol may surface to its quarry. This must stay comfortably * beyond the arrest radius: the first cut let the water search fall back to diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceRoster.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceRoster.java new file mode 100644 index 0000000..266f81e --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceRoster.java @@ -0,0 +1,92 @@ +package world.bentobox.tradewinds.crime; + +import java.util.ArrayList; +import java.util.List; + +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Who the law sends, and how many. Pure decision logic, unit tested headlessly + * like the galaxy - the Bukkit half only turns these into entities. + *

+ * Two rules shape every roster. Units are picked by mobility, so each + * one denies a different escape (spec section 7): golems take anyone ashore, + * guardians hold the water, and phantoms are the only thing that can follow a + * boat. And the response scales with the band, so safe space answers + * hard and lawless space barely answers at all - which is the whole reason to + * run cargo outward. + * + * @author tastybento + */ +public final class PoliceRoster { + + private PoliceRoster() { + // Static use only + } + + /** + * The response to a wanted player. + * + * @param band the island's security band + * @param size the band's configured patrol size + * @param ashore whether the target is standing on land + * @param fugitive whether the target is a fugitive rather than merely wanted + * @return the units to field, in spawn order + */ + public static List forWanted(SecurityBand band, int size, boolean ashore, boolean fugitive) { + List units = new ArrayList<>(); + if (size <= 0) { + return units; // This band has nobody to send + } + int total = fugitive ? size + 1 : size; + for (int i = 0; i < total; i++) { + if (ashore) { + // Golems hold the ground, with air support so leaving by boat + // is not a free escape + units.add(i % 3 == 2 ? PoliceUnit.PHANTOM : PoliceUnit.GOLEM); + } else { + // At sea: guardians deny the water, drowned close in, and a + // phantom keeps up if the target simply rows away + units.add(switch (i % 3) { + case 0 -> PoliceUnit.GUARDIAN; + case 1 -> PoliceUnit.DROWNED; + default -> PoliceUnit.PHANTOM; + }); + } + } + return units; + } + + /** + * The customs patrol sent after a smuggler. Always a sea response - customs + * launch from the harbour, and a detection happens at the border. + * + * @param size the band's configured patrol size + * @return the units to field + */ + public static List forCustoms(int size) { + List units = new ArrayList<>(); + for (int i = 0; i < Math.max(0, size); i++) { + units.add(i % 3 == 0 ? PoliceUnit.GUARDIAN : PoliceUnit.DROWNED); + } + return units; + } + + /** + * Whether the law has broken off. + *

+ * Police pursue inside the island's protection zone and give up at the + * border (spec section 7). Without this a wanted player could be followed + * across the ocean by an ever-growing escort, and the bands would stop + * meaning anything: the whole design is that lawless water is where the law + * is not. + * + * @param distanceFromIsland the target's distance from the island centre + * @param protectionRange the island's protection range + * @param breakOff how far past the border the law will still follow + * @return true if the pursuit should be called off + */ + public static boolean shouldBreakOff(double distanceFromIsland, int protectionRange, int breakOff) { + return distanceFromIsland > protectionRange + breakOff; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java new file mode 100644 index 0000000..fd23ec7 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java @@ -0,0 +1,249 @@ +package world.bentobox.tradewinds.crime; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Mob; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityCombustEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityTargetEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * The standing response to a wanted player: while you are inside a policed + * island's waters and the law wants you, the law comes. + *

+ * Three things keep this from becoming either a nuisance or a farm. Police + * break off at the border - the bands only mean something if lawless + * water is genuinely where the law is not, so an escort that followed you + * across the ocean would flatten the whole map into one difficulty. Police + * never leak: they are recalled when the pursuit ends, when the target + * logs out, changes world or stops being wanted, and they are non-persistent + * so an unloaded chunk takes them with it. And police drop nothing, + * which {@link CustomsListener} enforces for every tagged unit. + * + * @author tastybento + */ +public class PoliceService implements Listener { + + /** How often the response is re-examined, in ticks. */ + private static final long PERIOD = 40L; + + /** + * One standing response in progress. + * + * @param island the island whose law is responding + * @param units the units fielded + */ + private record Response(IslandSpec island, List units) { + } + + private final TradeWinds addon; + private final Map responses = new ConcurrentHashMap<>(); + private BukkitTask task; + + public PoliceService(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::tick, PERIOD, PERIOD); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + responses.values().forEach(response -> dispatch().recall(response.units())); + responses.clear(); + } + + private PoliceDispatch dispatch() { + return addon.getCustomsService().getPolice(); + } + + /** + * Re-examine every online player: dispatch, re-target, or break off. + */ + void tick() { + if (!addon.getSettings().isCrimeEnabled() || addon.getOverWorld() == null) { + return; + } + for (Player player : addon.getOverWorld().getPlayers()) { + if (player.getGameMode() != org.bukkit.GameMode.SURVIVAL || player.isDead()) { + recall(player.getUniqueId()); + continue; + } + Standing standing = addon.getReputationService().standing(player.getUniqueId()); + if (!standing.isHunted()) { + // Paid your fine, or decayed back to merely disreputable: the + // patrol stands down the moment you are no longer wanted + recall(player.getUniqueId()); + continue; + } + update(player, standing); + } + } + + private void update(Player player, Standing standing) { + Optional here = islandSpaceAt(player); + Response active = responses.get(player.getUniqueId()); + if (here.isEmpty()) { + recall(player.getUniqueId()); + return; + } + IslandSpec island = here.get(); + double distance = Math.sqrt(island.distanceSquared(player.getLocation().getBlockX(), + player.getLocation().getBlockZ())); + if (PoliceRoster.shouldBreakOff(distance, addon.getSettings().getIslandProtectionRange(), + addon.getSettings().getChaseBreakOffDistance())) { + recall(player.getUniqueId()); + return; + } + if (active == null) { + List units = dispatch().dispatchWanted(player, island, isAshore(player), + standing == Standing.FUGITIVE); + if (units.isEmpty()) { + return; // This band has nobody to send - lawless water + } + responses.put(player.getUniqueId(), new Response(island, units)); + User.getInstance(player).sendMessage("tradewinds.police.responding", "[name]", island.name()); + return; + } + // Mobs forget, and a target seated in a boat is forgotten fast: keep + // re-asserting, and drop units that have died + active.units().removeIf(unit -> !unit.isValid()); + if (active.units().isEmpty()) { + responses.remove(player.getUniqueId()); + return; + } + active.units().stream().filter(Mob.class::isInstance).map(Mob.class::cast) + .forEach(mob -> mob.setTarget(player)); + } + + /** + * Whether a player is standing on land rather than in or over water. + */ + private boolean isAshore(Player player) { + return player.getLocation().getBlockY() > addon.getSettings().getSeaHeight() + && player.getLocation().getBlock().getType() == org.bukkit.Material.AIR + && player.getVehicle() == null; + } + + private Optional islandSpaceAt(Player player) { + int x = player.getLocation().getBlockX(); + int z = player.getLocation().getBlockZ(); + int range = addon.getSettings().getIslandProtectionRange(); + return addon.getGalaxyEngine(addon.getOverWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(s -> s.distanceSquared(x, z) <= (long) range * range).findFirst(); + } + + /** + * Call off the pursuit of a player and remove its units. + * + * @param playerId the player + */ + public void recall(UUID playerId) { + Response response = responses.remove(playerId); + if (response != null) { + dispatch().recall(response.units()); + Player player = Bukkit.getPlayer(playerId); + if (player != null && player.isOnline()) { + User.getInstance(player).sendMessage("tradewinds.police.broken-off"); + } + } + } + + /** + * Whether the law is currently after a player. + * + * @param playerId the player + * @return true during a police response + */ + public boolean isPursued(UUID playerId) { + return responses.containsKey(playerId); + } + + /** + * A wanted player may be attacked anywhere, whatever the island's PvP + * setting says (spec section 7). BentoBox's own PvP listener cancels the + * damage at LOW priority, so this un-cancels it afterwards - bounty + * hunting has to be possible in the safe bands, or a wanted player could + * simply moor in high security and be untouchable. + */ + @EventHandler(priority = EventPriority.NORMAL) + public void onPvpDamage(EntityDamageByEntityEvent event) { + if (!addon.getSettings().isCrimeEnabled() || !event.isCancelled()) { + return; + } + if (!(event.getEntity() instanceof Player victim) || !addon.inWorld(victim.getWorld())) { + return; + } + Player attacker = CrimeListener.attacker(event); + if (attacker == null || attacker.equals(victim)) { + return; + } + if (addon.getReputationService().standing(victim.getUniqueId()).isLawfulTarget()) { + event.setCancelled(false); + } + } + + /** + * Police phantoms do not burn at dawn. A pursuit that ends because the sun + * came up is not a pursuit. + */ + @EventHandler(priority = EventPriority.HIGHEST) + public void onCombust(EntityCombustEvent event) { + if (addon.getCustomsService() != null && dispatch().isPolice(event.getEntity())) { + event.setCancelled(true); + } + } + + /** + * Police keep their assigned target and are not distracted by whoever + * wanders past. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onTarget(EntityTargetEvent event) { + if (addon.getCustomsService() == null || !dispatch().isPolice(event.getEntity())) { + return; + } + if (!(event.getTarget() instanceof Player player) + || !addon.getReputationService().standing(player.getUniqueId()).isHunted()) { + // Only cancel a re-target onto someone innocent; the service + // re-asserts the real target on its next tick + event.setCancelled(!isChasedByLaw(event.getTarget())); + } + } + + private boolean isChasedByLaw(Entity target) { + return target instanceof Player player + && (responses.containsKey(player.getUniqueId()) + || addon.getCustomsService().isChased(player.getUniqueId())); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + recall(event.getPlayer().getUniqueId()); + } + + @EventHandler + public void onWorldChange(PlayerChangedWorldEvent event) { + recall(event.getPlayer().getUniqueId()); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceUnit.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceUnit.java new file mode 100644 index 0000000..897f1c3 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceUnit.java @@ -0,0 +1,24 @@ +package world.bentobox.tradewinds.crime; + +/** + * The kinds of unit the law can field, chosen by mobility rather than by + * flavour (spec section 7). + *

+ * The point is that each one denies a different escape: a golem takes anyone + * who lands, guardians hold the water, and only phantoms can actually follow a + * boat. Fielding all three is what makes a policed island somewhere you have + * to think about rather than somewhere you row past. + * + * @author tastybento + */ +public enum PoliceUnit { + + /** Takes anyone who sets foot ashore. Cannot follow a boat. */ + GOLEM, + /** Holds the water around the island: area denial, never a pursuit. */ + GUARDIAN, + /** Wades and throws tridents - the sea patrol's muscle. */ + DROWNED, + /** The only unit that can chase a boat. Does not burn at dawn. */ + PHANTOM +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 5052880..fabe5f3 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -206,6 +206,26 @@ public Optional playerSellsAt(IslandSpec spec, Material material) { .map(base -> model().playerSellsAt(base * contrabandPremium(material), factor(spec, material))); } + /** + * Whether this island will deal with this player at all. + *

+ * A fugitive is barred from the safe bands entirely (spec principle 4): + * crime pays, into danger. Having burned your name, the only markets left + * are the ones where the law is thin - which is the same journey outward + * that smuggling forces, arrived at from the other direction. + * + * @param player the player + * @param spec the island + * @return true if the market is open to them + */ + public boolean willTradeWith(Player player, IslandSpec spec) { + if (addon.getReputationService() == null || !addon.getSettings().isCrimeEnabled()) { + return true; + } + return !addon.getReputationService().standing(player.getUniqueId()).isBarredFromSafeTrade() + || spec.band().ordinal() >= addon.getSettings().safestFugitiveTrader().ordinal(); + } + /** * The smuggler's premium. *

diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 0695910..2c949af 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -50,6 +50,11 @@ public TradeDialog(TradeWinds addon) { * Open the market's main menu. */ public void openMain(Player player, IslandSpec spec) { + // A fugitive's money is no good in the safe bands at all + if (!addon.getMarketService().willTradeWith(player, spec)) { + User.getInstance(player).sendMessage("tradewinds.trade.barred"); + return; + } List buttons = new ArrayList<>(); // No sell button when the hold has nothing this island pays for if (!sellOffers(player, spec).isEmpty()) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0db0117..4074795 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -166,6 +166,15 @@ crime: # A fine can only ever take you back to Clean - money buys you out of Wanted, # not into virtue. fine-per-point: 2.0 + # The safest band that will still trade with a FUGITIVE. Safer ports refuse them + # outright: having burned your name, the only markets left are where the law is + # thin. SAFE, POLICED, FRONTIER, LAWLESS or ANARCHIC. + safest-fugitive-trader: FRONTIER + # Show a player's bounty beside their name (scoreboard team suffix). A bounty + # nobody can see is not a bounty - a hunter has to be able to tell at a glance. + # Turn OFF if the server runs TAB or another nametag plugin (they will fight over + # the team) and render %tradewinds_bounty% there instead. + bounty-nameplate: true # Reputation lost per crime. penalties: HURT_VILLAGER: -5 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 1ac99f7..477e343 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -83,6 +83,10 @@ tradewinds: fine-cannot-afford: "You cannot afford the fine ([amount])." fine-no-market: "Fines are paid at a trading island, not out here." fine-barred: "'We don't take money from your sort.' Try a rougher port." + police: + responding: "[name] has patrols out for you. They will not follow you past the border." + broken-off: "The patrol turns back at the border." + bounty-tag: " [[amount]]" customs: clean: "Customs board you, look through the hold, and wave you on." detected: "[title]CUSTOMS[subtitle]Patrol dispatched from [name]" @@ -174,6 +178,7 @@ tradewinds: not-at-market: "There is no market here - trade within an island's protection range." not-stamped: "Traders only buy customs-stamped goods (or... certain other merchandise)." contraband-refused: "The trader looks at what you are offering, then at you. 'Not here. Try a rougher port.'" + barred: "The market closes as you approach. 'We know what you are. Take it somewhere lawless.'" nothing-to-sell: "Nothing in your hold that this island will pay for." nothing-for-sale: "This island has nothing for sale - it only buys." sold: "Sold [amount] x [material] for $[price]." diff --git a/src/test/java/world/bentobox/tradewinds/crime/PoliceRosterTest.java b/src/test/java/world/bentobox/tradewinds/crime/PoliceRosterTest.java new file mode 100644 index 0000000..e0dbcd3 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/crime/PoliceRosterTest.java @@ -0,0 +1,95 @@ +package world.bentobox.tradewinds.crime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Headless tests of who the law sends and when it gives up. + * + * @author tastybento + */ +class PoliceRosterTest { + + /** The configured default patrol sizes. */ + private static final Map SIZES = Map.of(SecurityBand.SAFE, 4, SecurityBand.POLICED, 3, + SecurityBand.FRONTIER, 2, SecurityBand.LAWLESS, 1, SecurityBand.ANARCHIC, 0); + + @Test + void testLawlessSpaceSendsNobody() { + // The bands only mean something if the far ones are genuinely unpoliced + assertTrue(PoliceRoster.forWanted(SecurityBand.ANARCHIC, 0, false, false).isEmpty()); + assertTrue(PoliceRoster.forCustoms(0).isEmpty()); + } + + @Test + void testResponseShrinksAsTheLawThinsOut() { + int previous = Integer.MAX_VALUE; + for (SecurityBand band : SecurityBand.values()) { + int size = PoliceRoster.forWanted(band, SIZES.get(band), false, false).size(); + assertTrue(size <= previous, "Response grew at " + band); + previous = size; + } + assertTrue(PoliceRoster.forWanted(SecurityBand.SAFE, 4, false, false).size() > PoliceRoster + .forWanted(SecurityBand.LAWLESS, 1, false, false).size()); + } + + @Test + void testOnlyPhantomsCanFollowABoat() { + // Each unit denies a different escape. A sea response with no phantom + // would be a response a player simply rows away from. + List atSea = PoliceRoster.forWanted(SecurityBand.SAFE, 4, false, false); + assertTrue(atSea.contains(PoliceUnit.PHANTOM), "A sea response must be able to pursue: " + atSea); + assertTrue(atSea.contains(PoliceUnit.GUARDIAN), "Guardians hold the water: " + atSea); + assertFalse(atSea.contains(PoliceUnit.GOLEM), "A golem cannot swim after a boat: " + atSea); + } + + @Test + void testAshoreTheGolemsTakeOver() { + List ashore = PoliceRoster.forWanted(SecurityBand.SAFE, 4, true, false); + assertTrue(ashore.contains(PoliceUnit.GOLEM), "Golems take anyone who lands: " + ashore); + // ... with air support, so bolting for the boat is not free + assertTrue(ashore.contains(PoliceUnit.PHANTOM), "Ashore still needs pursuit: " + ashore); + assertFalse(ashore.contains(PoliceUnit.GUARDIAN), "A guardian on a plaza is an ornament: " + ashore); + } + + @Test + void testAFugitiveDrawsMore() { + int wanted = PoliceRoster.forWanted(SecurityBand.POLICED, 3, false, false).size(); + int fugitive = PoliceRoster.forWanted(SecurityBand.POLICED, 3, false, true).size(); + assertTrue(fugitive > wanted, "Shoot on sight should mean more of them"); + } + + @Test + void testCustomsAlwaysAnswerFromTheSea() { + // Customs launch from a harbour at a border crossing, so the roster is + // never a land one however the roll came up + List customs = PoliceRoster.forCustoms(4); + assertEquals(4, customs.size()); + assertFalse(customs.contains(PoliceUnit.GOLEM)); + assertTrue(customs.contains(PoliceUnit.GUARDIAN)); + assertTrue(customs.contains(PoliceUnit.DROWNED)); + } + + @Test + void testPursuitBreaksOffAtTheBorder() { + int range = 400; + int breakOff = 400; + // Inside the protection zone the law follows + assertFalse(PoliceRoster.shouldBreakOff(0, range, breakOff)); + assertFalse(PoliceRoster.shouldBreakOff(399, range, breakOff)); + // Just past the border it still follows a way + assertFalse(PoliceRoster.shouldBreakOff(700, range, breakOff)); + // Beyond that it gives up: lawless water has to stay lawless, or the + // security bands flatten into one difficulty everywhere + assertTrue(PoliceRoster.shouldBreakOff(801, range, breakOff)); + assertTrue(PoliceRoster.shouldBreakOff(10_000, range, breakOff)); + } +} From 8a24166382308e88efa66ba7e29f2662c7df0d74 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 08:20:47 -0700 Subject: [PATCH 056/112] Stop a failed warp from killing new players outright Playtest: a first-ever warp failed, ghasts opened fire instantly, and the player died having lost everything before they could read the dialog offering the free way out. The numbers were indefensible: ghasts spawned at 20-35 blocks, well inside their own 64-block detection range, with setTarget called on arrival - so they were hunting before the player finished loading in. And at least one always came. The interstice is meant to be a detour with a way out, not a death sentence. Now some failures are just dark water (ghast-chance 0.6); when ghasts do come they appear beyond their own detection range and are not given a target, so engaging is the player's choice, the same rule sea encounters already follow; and for 20 seconds after arrival nothing may target or damage them, which also covers a fireball already in flight. The grace covers the arrival only - stay and pick a fight and it is a fight. 197 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 20 ++++++ docs/PROGRESS.md | 30 +++++++++ .../world/bentobox/tradewinds/Settings.java | 23 +++++++ .../world/bentobox/tradewinds/TradeWinds.java | 2 + .../travel/IntersticeGraceListener.java | 61 +++++++++++++++++++ .../tradewinds/travel/IntersticeService.java | 47 +++++++++++++- src/main/resources/config.yml | 13 +++- 7 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/travel/IntersticeGraceListener.java diff --git a/TESTING.md b/TESTING.md index 09d9304..e93c59d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -781,3 +781,23 @@ Get yourself **Wanted** (−200: one murder, or ~8 villager kills) with all ("We know what you are"). - [ ] At FRONTIER and beyond it opens normally. Merely **Wanted** is not barred — only Fugitive. (`crime.safest-fugitive-trader`.) + +## Interstice: a failed warp must not be an execution + +Playtest: a new player's first warp failed, ghasts opened fire instantly, and they +died having lost everything before they could read the dialog offering the free way +out. Set `travel.warp.failure-chance: 1.0` temporarily to test this repeatedly. + +- [ ] Fail a warp several times. Roughly **40% of the time nothing comes at all** — + dark water and silence (`interstice.ghast-chance`). +- [ ] When ghasts do come, they appear **~90+ blocks away** and are **not already + hunting you**. You can see them, and decide. (They spawn beyond their own + 64-block detection range and are deliberately not given a target.) +- [ ] For **20 seconds after arrival** nothing can target or damage you + (`interstice.grace-seconds`) — long enough to read the dialog and click + re-engage. Confirm a fireball already in the air does not kill you either. +- [ ] Re-engaging is still free, and still returns you to the original destination. +- [ ] Stay past the grace and approach them: it becomes a real fight. The grace + covers the arrival, not the visit. +- [ ] A brand new player with a starter kit can survive a failed first warp without + losing their boat and cargo. That is the actual bar here. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index bb4ecba..20165c8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,36 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Interstice: a failed warp was killing new players (2026-08-01) + +Playtest: "I was warping and got sent to the Nether. The ghasts immediately +started to attack... a dialog box popped up that I was too frightened to read +... I just died and lost everything after one warp." + +The numbers were indefensible. Ghasts spawned at **20-35 blocks** - well inside +a ghast's 64-block detection range - and `setTarget(player)` was called on +arrival, so they were already hunting before the player had finished loading in. +There was no chance of an empty interstice: at least one always came. A 5% warp +failure meant a new player's first jump could cost them the boat, the cargo and +the kit, for nothing they did wrong. + +The interstice is meant to be a **detour with a way out**, not a death sentence +(spec 3.3: the fuel is already spent, so re-engaging is free). Three changes: +- `interstice.ghast-chance` (0.6): sometimes nothing comes at all. Dark water + and a long silence is unsettling on its own, and it means a failed warp is not + automatically a fight. +- `interstice.ghast-distance` (90): ghasts appear **beyond their own detection + range** and are no longer given a target. They are a thing you can see and + decide about, which is the same rule the sea encounters already follow. +- `interstice.grace-seconds` (20): for a short window nothing may target or + damage a new arrival, so the dialog can actually be read. It also covers a + fireball already in flight - clicking "re-engage the warp" and dying to a shot + fired before you read it is precisely the reported experience. + +The grace covers the arrival only; stay and pick a fight and it is a fight. + +197 tests green. + ## Stage 6c — police, wanted response, bounties (2026-08-01) Closes Stage 6. The law now answers for itself: while you are inside a policed diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index f83a3cd..df8e342 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -567,6 +567,23 @@ private static Map defaultBasePrices() { } /* INTERSTICE */ + @ConfigComment("Chance (0-1) that anything is waiting when a warp fails. Below 1 some failures") + @ConfigComment("are just dark water and a long silence, which is unsettling in its own right -") + @ConfigComment("and means a failed warp is not automatically a fight.") + @ConfigEntry(path = "interstice.ghast-chance") + private double intersticeGhastChance = 0.6; + + @ConfigComment("How far away ghasts appear, in blocks. Keep this ABOVE a ghast's own 64-block") + @ConfigComment("detection range: they are not told where the player is, so at this distance") + @ConfigComment("engaging is the player's choice rather than something done to them.") + @ConfigEntry(path = "interstice.ghast-distance") + private double intersticeGhastDistance = 90; + + @ConfigComment("Seconds after a failed warp during which nothing in the interstice may target") + @ConfigComment("or hurt the player - long enough to read the dialog and take the free way out.") + @ConfigEntry(path = "interstice.grace-seconds") + private int intersticeGraceSeconds = 20; + @ConfigComment("Ghasts spawned around a stranded sailor in the interstice.") @ConfigEntry(path = "interstice.ghasts-min") private int intersticeGhastsMin = 1; @@ -2870,6 +2887,12 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } public Map getBasePrices() { return basePrices; } public void setBasePrices(Map basePrices) { this.basePrices = basePrices; } + public double getIntersticeGhastChance() { return intersticeGhastChance; } + public void setIntersticeGhastChance(double v) { this.intersticeGhastChance = v; } + public double getIntersticeGhastDistance() { return intersticeGhastDistance; } + public void setIntersticeGhastDistance(double v) { this.intersticeGhastDistance = v; } + public int getIntersticeGraceSeconds() { return intersticeGraceSeconds; } + public void setIntersticeGraceSeconds(int v) { this.intersticeGraceSeconds = v; } public int getIntersticeGhastsMin() { return intersticeGhastsMin; } public void setIntersticeGhastsMin(int intersticeGhastsMin) { this.intersticeGhastsMin = intersticeGhastsMin; } public int getIntersticeGhastsMax() { return intersticeGhastsMax; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index e6da133..67608c1 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -263,6 +263,8 @@ public void onEnable() { // Risk at sea: the interstice for warpers, encounters for rowers intersticeService = new IntersticeService(this); intersticeService.start(); + // A moment to read the way out before anything in there notices you + registerListener(new world.bentobox.tradewinds.travel.IntersticeGraceListener(this)); encounterService = new EncounterService(this); encounterService.start(); registerListener(new EncounterListener(this)); diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeGraceListener.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeGraceListener.java new file mode 100644 index 0000000..c20d9df --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeGraceListener.java @@ -0,0 +1,61 @@ +package world.bentobox.tradewinds.travel; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityTargetLivingEntityEvent; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * A moment to get your bearings after a failed warp. + *

+ * Playtest: a player's first ever warp failed, ghasts opened fire immediately, + * and the dialog offering the free way out went unread because reading it meant + * dying. The interstice is meant to be a detour with a way out, not a death + * sentence handed to someone who did nothing wrong - so for a few seconds after + * arrival nothing in there may target or hurt them. + *

+ * The grace covers the arrival only. Stay and pick a fight and it is a fight. + * + * @author tastybento + */ +public class IntersticeGraceListener implements Listener { + + private final TradeWinds addon; + + public IntersticeGraceListener(TradeWinds addon) { + this.addon = addon; + } + + private boolean isProtected(Player player) { + return addon.getNetherWorld() != null && player.getWorld().equals(addon.getNetherWorld()) + && addon.getIntersticeService().isInGrace(player.getUniqueId()); + } + + /** + * Nothing in the interstice notices a new arrival for the grace window. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onTarget(EntityTargetLivingEntityEvent event) { + if (event.getTarget() instanceof Player player && isProtected(player)) { + event.setTarget(null); + event.setCancelled(true); + } + } + + /** + * ... and a fireball already in the air cannot land on them either. Clicking + * "re-engage the warp" and dying to a shot fired before you read it is + * exactly the experience this is here to prevent. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onDamage(EntityDamageEvent event) { + if (event.getEntity() instanceof Player player && isProtected(player) + && event.getCause() != EntityDamageEvent.DamageCause.VOID) { + event.setCancelled(true); + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index f2b852c..34540b8 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -52,6 +52,8 @@ public class IntersticeService { /** Player -> the destination cell they are still owed, free of charge. */ private final Map pendingDestination = new ConcurrentHashMap<>(); private final Map lastPrompt = new ConcurrentHashMap<>(); + /** Player -> when they were stranded, for the arrival grace. */ + private final Map arrivals = new ConcurrentHashMap<>(); private final TradeWinds addon; private BukkitTask task; @@ -105,28 +107,67 @@ public void strand(Player player, IslandSpec from, IslandSpec to) { target.getWorld().spawnParticle(Particle.PORTAL, target, 120, 2, 2, 2, 0.6); target.getWorld().playSound(target, Sound.ENTITY_GHAST_SCREAM, 1.0f, 0.7f); User.getInstance(player).sendMessage("tradewinds.interstice.stranded"); + arrivals.put(player.getUniqueId(), System.currentTimeMillis()); spawnGhasts(player, target); Bukkit.getPluginManager().callEvent(new TWWarpFailedEvent(player, from, to)); }); } + /** + * Put whatever is out there in the dark, at a distance. + *

+ * The first cut spawned ghasts at 20-35 blocks and called + * {@code setTarget} on arrival - inside a ghast's 64-block detection range + * and already hunting. A player whose very first warp failed was under fire + * before they could read the dialog offering them the way out, and died + * having lost everything. A failed warp is meant to be a detour, not an + * execution. + *

+ * So: sometimes nothing comes at all, and when it does it starts beyond its + * own detection range and is not told where the player is. Engaging is the + * player's choice - re-engage the warp and leave, or go hunting. + */ private void spawnGhasts(Player player, Location around) { + if (Math.random() >= addon.getSettings().getIntersticeGhastChance()) { + return; // Dark water and nothing in it. The interstice is unsettling enough. + } int min = addon.getSettings().getIntersticeGhastsMin(); int count = min + (int) (Math.random() * Math.max(1, addon.getSettings().getIntersticeGhastsMax() - min + 1)); + double near = addon.getSettings().getIntersticeGhastDistance(); for (int i = 0; i < count; i++) { double angle = Math.random() * Math.PI * 2; - double distance = 20 + Math.random() * 15; - Location spot = around.clone().add(Math.cos(angle) * distance, 12 + Math.random() * 8, + double distance = near + Math.random() * 30; + Location spot = around.clone().add(Math.cos(angle) * distance, 10 + Math.random() * 10, Math.sin(angle) * distance); Entity ghast = around.getWorld().spawnEntity(spot, EntityType.GHAST); if (ghast instanceof Ghast g) { - g.setTarget(player); + // Deliberately NOT targeted: let the player decide whether this + // is a fight g.setRemoveWhenFarAway(true); } } } + /** + * How long a freshly stranded sailor is left alone, in millis. + */ + private long graceMillis() { + return addon.getSettings().getIntersticeGraceSeconds() * 1000L; + } + + /** + * Whether a player is still inside their arrival grace - long enough to + * read the dialog and take the free way out. + * + * @param playerId the player + * @return true during the grace window + */ + public boolean isInGrace(UUID playerId) { + Long arrived = arrivals.get(playerId); + return arrived != null && System.currentTimeMillis() - arrived < graceMillis(); + } + /** * Offer the free re-engage to interstice castaways, and clean up. */ diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4074795..8285a76 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -293,7 +293,18 @@ player: # Career restarts a destitute player may use (/tw restart). -1 unlimited, 0 none. max-restarts: 3 interstice: - # Ghasts spawned around a stranded sailor. + # Chance (0-1) that anything is waiting when a warp fails. Below 1, some failures + # are just dark water and a long silence - unsettling in its own right, and it + # means a failed warp is not automatically a fight. + ghast-chance: 0.6 + # How far away ghasts appear, in blocks. Keep ABOVE a ghast's own 64-block + # detection range: they are not told where the player is, so at this distance + # engaging is the player's choice rather than something done to them. + ghast-distance: 90 + # Seconds after a failed warp during which nothing in the interstice may target or + # hurt the player - long enough to read the dialog and take the free way out. + grace-seconds: 20 + # Ghasts spawned around a stranded sailor, when any come at all. ghasts-min: 1 ghasts-max: 3 # Seconds between offers of the free re-engage while adrift. From b714f5b1b7dfa1e9062ac9a3adc5fd753ae68ce7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 08:30:35 -0700 Subject: [PATCH 057/112] Fix suffocation on warp arrival, and add /twadmin warpfail Death screen: "suffocated in a wall whilst fighting Ghast", overworld nav bar showing Edatge | Dock 136m. The ghast was recent-damage attribution carried over from the interstice after re-engaging the warp. Two hypotheses died before the real one. Vanilla nether decoration scattering blocks in the interstice's open air: checked by parsing the region files - 37,024 sections above y=72, not one non-air block. Ragged coastlines reaching past the arrival ring: a sweep of every bearing around every island within 12,000 blocks found the arrival point never lands on natural terrain. The cause was in the nav bar. A warp arrives at a fixed 130 blocks from the island centre at sea level + 1, and the quay runs out to 136 blocks with its plank deck at exactly that height. Line the approach bearing up with the dock bearing - 0.9% of bearings do - and the sailor materialises inside the decking. The arrival code never asked what was there. SeaArrival now finds the nearest open water to the intended point and puts the arrival there, for warps and interstice strandings alike. The test asserts the quay collision exists and that natural land does not, so nobody re-fixes the coastline. Also /twadmin warpfail : rigs the next warp to fail, toggles, and is consumed on use so it cannot sit forgotten on an account. Testing tool and live mischief. 200 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 13 +++ docs/PROGRESS.md | 39 ++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 2 + .../commands/AdminWarpFailCommand.java | 68 +++++++++++++ .../tradewinds/travel/IntersticeService.java | 57 ++++++++++- .../tradewinds/travel/SeaArrival.java | 97 ++++++++++++++++++ .../tradewinds/travel/WarpService.java | 10 +- src/main/resources/addon.yml | 3 + src/main/resources/locales/en-US.yml | 5 + .../tradewinds/travel/SeaArrivalTest.java | 98 +++++++++++++++++++ 10 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/AdminWarpFailCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java diff --git a/TESTING.md b/TESTING.md index e93c59d..38676a7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -801,3 +801,16 @@ out. Set `travel.warp.failure-chance: 1.0` temporarily to test this repeatedly. covers the arrival, not the visit. - [ ] A brand new player with a starter kit can survive a failed first warp without losing their boat and cargo. That is the actual bar here. + +## Warp arrivals must never land inside anything + +- [ ] Warp repeatedly to the same island from the same origin. You always arrive on + **open water**, never inside the quay decking. (The arrival ring is 130 blocks + from the centre; the quay reaches 136 with its deck at exactly arrival height, + so ~0.9% of bearings used to suffocate you on arrival.) +- [ ] Arrivals still land close to the intended spot - the search takes the nearest + open water, so the island should still be in view. +- [ ] `/twadmin warpfail ` rigs that player's next warp to fail. Run it + again on the same player to clear it. Check the console/chat feedback both ways. +- [ ] After a rigged failure, the flag is gone: the next warp behaves normally. +- [ ] Interstice arrivals also land on open water, not inside the interstice floor. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 20165c8..d50bf70 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,45 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## "Suffocated in a wall": warp arrivals landing inside the quay (2026-08-01) + +Ben's death screen: *"BoxManager suffocated in a wall whilst fighting Ghast"*, +with the overworld navigation bar showing **Edatge | Dock 136m**. The ghast was +a red herring - it was the recent-damage attribution from the interstice, still +credited a few seconds after he re-engaged the warp and left. + +Two hypotheses died before the real one. First: vanilla nether decoration +scattering blocks in the interstice's open air. **Checked it** by parsing the +interstice region files - 37,024 sections above y=72 and not one non-air block. +Second: ragged coastlines reaching past the arrival ring. Also wrong - a sweep +of every bearing around every island within 12,000 blocks found the arrival +point never lands on natural terrain. + +The actual cause was in the nav bar all along. A warp arrives at a fixed 130 +blocks from the island centre at **sea level + 1**, and the quay runs out to 136 +blocks with its plank deck at **exactly that height**. Line the approach bearing +up with the dock bearing - 0.9% of bearings do - and the warp materialises the +sailor inside the decking. The arrival code never asked what was there. + +`SeaArrival` now finds the nearest **open water** to the intended point +(searching outward, nearest first) and puts the arrival there, for both warp +arrivals and interstice strandings. Arrivals are by boat; open water is the only +sensible answer. The regression test asserts the quay collision exists *and* +that natural land does not, so the next person to read it does not go re-fixing +the coastline. + +Also added `/twadmin warpfail `: rigs a player's next warp to fail. +A 5% chance is miserable to reproduce on demand and the interstice needs testing +far more often than that. It toggles, and the flag is consumed by the next warp +either way, so it cannot sit forgotten on an account. Doubles as a live tool for +spicing up a session. + +**Pitfall:** two plausible explanations, both wrong, and each would have led to a +real but pointless change. Parsing the world file took a few minutes and settled +it - measuring beat guessing again. + +200 tests green. + ## Interstice: a failed warp was killing new players (2026-08-01) Playtest: "I was warping and got sent to the Nether. The ghasts immediately diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 67608c1..cd82809 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -29,6 +29,7 @@ import world.bentobox.tradewinds.commands.AdminIslandsCommand; import world.bentobox.tradewinds.commands.AdminReflagCommand; import world.bentobox.tradewinds.commands.AdminTpIslandCommand; +import world.bentobox.tradewinds.commands.AdminWarpFailCommand; import world.bentobox.tradewinds.commands.TWChartCommand; import world.bentobox.tradewinds.commands.TWFineCommand; import world.bentobox.tradewinds.commands.TWRestartCommand; @@ -185,6 +186,7 @@ public void setup() { new AdminTpIslandCommand(this); new AdminReflagCommand(this); new AdminCustomsCommand(this); + new AdminWarpFailCommand(this); } }; } diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminWarpFailCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminWarpFailCommand.java new file mode 100644 index 0000000..33248f8 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminWarpFailCommand.java @@ -0,0 +1,68 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.travel.IntersticeService; + +/** + * Rig a player's next warp to fail, dropping them into the interstice. + *

+ * Written because a 5% failure chance is miserable to reproduce on demand, and + * the interstice needs testing far more often than it happens. It is also a + * live tool: an admin can put a particular sailor into the dark to liven up a + * session. Running it again on the same player clears the flag, and the flag is + * consumed by their next warp either way - it can never sit forgotten on + * somebody's account. + * + * @author tastybento + */ +public class AdminWarpFailCommand extends CompositeCommand { + + public AdminWarpFailCommand(CompositeCommand parent) { + super(parent, "warpfail"); + } + + @Override + public void setup() { + setPermission("admin.warpfail"); + setOnlyPlayer(false); + setParametersHelp("tradewinds.commands.admin.warpfail.parameters"); + setDescription("tradewinds.commands.admin.warpfail.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.size() != 1) { + showHelp(this, user); + return false; + } + UUID target = getPlayers().getUUID(args.get(0)); + if (target == null) { + user.sendMessage("general.errors.unknown-player", TextVariables.NAME, args.get(0)); + return false; + } + String name = getPlayers().getName(target); + IntersticeService interstice = ((TradeWinds) getAddon()).getIntersticeService(); + // Toggle, so the same command undoes a rig set by mistake + if (interstice.isRigged(target)) { + interstice.clearRig(target); + user.sendMessage("tradewinds.commands.admin.warpfail.cleared", TextVariables.NAME, name); + return true; + } + interstice.rigNextWarp(target); + user.sendMessage("tradewinds.commands.admin.warpfail.rigged", TextVariables.NAME, name); + return true; + } + + @Override + public Optional> tabComplete(User user, String alias, List args) { + return Optional.of(Util.getOnlinePlayerList(user)); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index 34540b8..929b75b 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -52,6 +52,8 @@ public class IntersticeService { /** Player -> the destination cell they are still owed, free of charge. */ private final Map pendingDestination = new ConcurrentHashMap<>(); private final Map lastPrompt = new ConcurrentHashMap<>(); + /** Players whose next warp is rigged to fail, consumed on use. */ + private final java.util.Set rigged = java.util.concurrent.ConcurrentHashMap.newKeySet(); /** Player -> when they were stranded, for the arrival grace. */ private final Map arrivals = new ConcurrentHashMap<>(); @@ -80,6 +82,55 @@ public boolean rollFailure() { return Math.random() < addon.getSettings().getWarpFailureChance(); } + /** + * Does this player's warp fail? Honours a rigged failure first, consuming + * it, then falls back to the ordinary roll. + * + * @param playerId the warping player + * @return true if this warp drops them into the interstice + */ + public boolean rollFailure(UUID playerId) { + if (rigged.remove(playerId)) { + return true; + } + return rollFailure(); + } + + /** + * Rig a player's next warp to fail. + *

+ * Written for testing - a 5% failure is miserable to reproduce on demand - + * but it is also a live tool: an admin can put a specific sailor into the + * interstice to liven up a session. The flag is consumed by the next warp, + * so it can never sit forgotten on someone's account. + * + * @param playerId the player to rig + * @return true if they were not already rigged + */ + public boolean rigNextWarp(UUID playerId) { + return rigged.add(playerId); + } + + /** + * Clear a rigged failure without using it. + * + * @param playerId the player + * @return true if a rig was cleared + */ + public boolean clearRig(UUID playerId) { + return rigged.remove(playerId); + } + + /** + * Whether a player's next warp is rigged to fail. + * + * @param playerId the player + * @return true if rigged + */ + public boolean isRigged(UUID playerId) { + return rigged.contains(playerId); + } + /** * Drop a player into the interstice partway along their route. */ @@ -92,8 +143,10 @@ public void strand(Player player, IslandSpec from, IslandSpec to) { double fraction = 0.35 + Math.random() * 0.3; int x = (int) Math.round(from.centerX() + (to.centerX() - from.centerX()) * fraction); int z = (int) Math.round(from.centerZ() + (to.centerZ() - from.centerZ()) * fraction); - Location target = new Location(addon.getNetherWorld(), x + 0.5, - addon.getSettings().getIntersticeSeaHeight() + 1.0, z + 0.5); + // Open water here too: the interstice has its own sea floor, and + // dropping a castaway inside it would be the same suffocation bug + Location target = SeaArrival.openSeaNear(addon.getNetherWorld(), x, z, + addon.getSettings().getIntersticeSeaHeight()); Entity vehicle = player.getVehicle(); if (vehicle != null) { diff --git a/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java b/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java new file mode 100644 index 0000000..68be476 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java @@ -0,0 +1,97 @@ +package world.bentobox.tradewinds.travel; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; + +/** + * Finds open water to arrive on. + *

+ * A warp used to land the player at a fixed distance from the island centre, + * at sea level, without ever asking what was there. That was survivable while + * coastlines were perfect circles of a known radius - and then ragged coasts + * arrived, headlands started reaching past the arrival ring, and a warp could + * materialise a sailor inside a hillside. The death message reads "suffocated + * in a wall", which is not a sentence any player should have to interpret. + *

+ * Arrivals are by boat, so the rule is simple: put them on open water, as near + * the intended spot as possible. + * + * @author tastybento + */ +public final class SeaArrival { + + /** How far to search outward for water before giving up, in blocks. */ + private static final int SEARCH_RADIUS = 160; + /** Step between rings - a boat-width apart is fine and keeps this cheap. */ + private static final int STEP = 4; + + private SeaArrival() { + // Static use only + } + + /** + * Candidate offsets around a point, nearest first. Pure and testable: the + * ordering is what guarantees an arrival lands as close to its intended + * spot as the terrain allows. + * + * @param radius how far out to search + * @param step spacing between candidates + * @return offsets ordered by distance from the origin + */ + public static List searchOffsets(int radius, int step) { + List offsets = new ArrayList<>(); + for (int dx = -radius; dx <= radius; dx += step) { + for (int dz = -radius; dz <= radius; dz += step) { + if (dx * dx + dz * dz <= radius * radius) { + offsets.add(new int[] { dx, dz }); + } + } + } + offsets.sort(Comparator.comparingInt(o -> o[0] * o[0] + o[1] * o[1])); + return offsets; + } + + /** + * Whether a column is open water a boat can float on: water at sea level, + * and clear air above it for the sailor's head. + * + * @param world the world + * @param x block x + * @param z block z + * @param seaHeight the sea surface Y + * @return true if it is somewhere to arrive + */ + public static boolean isOpenSea(World world, int x, int z, int seaHeight) { + return world.getBlockAt(x, seaHeight, z).getType() == Material.WATER + && world.getBlockAt(x, seaHeight + 1, z).isEmpty() + && world.getBlockAt(x, seaHeight + 2, z).isEmpty(); + } + + /** + * The nearest open water to an intended arrival point. + * + * @param world the world to arrive in + * @param x intended block x + * @param z intended block z + * @param seaHeight the sea surface Y + * @return a location on open water, or the intended point raised above the + * terrain if this whole area is somehow solid + */ + public static Location openSeaNear(World world, int x, int z, int seaHeight) { + for (int[] offset : searchOffsets(SEARCH_RADIUS, STEP)) { + int cx = x + offset[0]; + int cz = z + offset[1]; + if (isOpenSea(world, cx, cz, seaHeight)) { + return new Location(world, cx + 0.5, seaHeight + 1.0, cz + 0.5); + } + } + // Nothing but land for 160 blocks: put them on top of it rather than + // inside it. Better a strange arrival than a suffocation. + return new Location(world, x + 0.5, world.getHighestBlockYAt(x, z) + 1.0, z + 0.5); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index 1915b60..fb1116b 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -197,7 +197,7 @@ private void jump(Player player, IslandSpec from, IslandSpec to, int fuelCost) { // inside view distance, so the island is right there in front of you // The warp does not always hold: a failure drops the sailor into the // interstice, still owed this destination (spec 3.3) - if (addon.getIntersticeService().rollFailure()) { + if (addon.getIntersticeService().rollFailure(player.getUniqueId())) { addon.getIntersticeService().strand(player, from, to); return; } @@ -214,8 +214,12 @@ public void deliver(Player player, IslandSpec to) { private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int fuelCost) { int[] arrive = RouteGraph.arrivalPoint(bearingFrom, to, addon.getSettings().getWarpArrivalDistance()); - Location target = new Location(addon.getOverWorld(), arrive[0] + 0.5, - addon.getSettings().getSeaHeight() + 1.0, arrive[1] + 0.5); + // Arrive on open water. The nominal arrival ring is a fixed distance + // from the island centre, and since coastlines gained headlands it can + // fall on land - which used to materialise the sailor inside a hillside + // and kill them ("suffocated in a wall"). + Location target = SeaArrival.openSeaNear(addon.getOverWorld(), arrive[0], arrive[1], + addon.getSettings().getSeaHeight()); // Dismount -> teleport player and boat -> re-seat (AcidIsland /ai pattern) Entity vehicle = player.getVehicle(); diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 1006302..37f1902 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -62,6 +62,9 @@ permissions: tradewinds.admin.customs: description: Show the customs state at your position (contraband, scan odds, patrol) default: op + tradewinds.admin.warpfail: + description: Rig a player's next warp to fail, dropping them into the interstice + default: op tradewinds.admin.reflag: description: Allow re-applying security-band flags to all islands default: op diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 477e343..7407ae9 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -55,6 +55,11 @@ tradewinds: buys: "This port buys contraband: [value]" patrol: "Patrol it can field: [number]" chased: "Chase in progress: [value]" + warpfail: + description: "rig a player's next warp to fail (run again to clear)" + parameters: "" + rigged: "[name]'s next warp will fail. Run this again to clear it." + cleared: "[name]'s warp is no longer rigged." reflag: description: "re-apply security-band flags to all trading islands" done: "Re-applied band flags to [number] trading islands." diff --git a/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java b/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java new file mode 100644 index 0000000..8f20309 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java @@ -0,0 +1,98 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.galaxy.ColumnPlan; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.RouteGraph; + +/** + * Why warp arrivals have to look before they land. + * + * @author tastybento + */ +class SeaArrivalTest { + + private static final long SEED = 20260729L; + private static final int SEA = 70; + private static final int ARRIVAL_DISTANCE = 130; + + private GalaxyEngine engine() { + return new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.5, 6, 5000, SEA)); + } + + @Test + void testTheArrivalRingCanLandOnTheQuay() { + // The bug behind "suffocated in a wall". The arrival point is a fixed + // 130 blocks from an island's centre at sea level + 1 - and the quay + // runs out to 136 blocks with its plank deck at exactly that height. + // Line the approach bearing up with the dock bearing and the warp + // materialises the sailor inside the decking. + // + // Natural land is NOT the culprit: the coast stops short of the ring. + // It is the one structure that deliberately reaches past it. + GalaxyEngine engine = engine(); + int onQuay = 0; + int onLand = 0; + int tested = 0; + for (IslandSpec island : engine.islandsNear(0, 0, 12_000)) { + for (int deg = 0; deg < 360; deg += 5) { + double rad = Math.toRadians(deg); + IslandSpec from = new IslandSpec(0, 0, + island.centerX() + (int) Math.round(Math.cos(rad) * 5000), + island.centerZ() + (int) Math.round(Math.sin(rad) * 5000), island.type(), + island.band(), island.biomeKey(), "approach"); + int[] arrive = RouteGraph.arrivalPoint(from, island, ARRIVAL_DISTANCE); + tested++; + if (engine.columnPlanAt(arrive[0], arrive[1]) + .filter(plan -> plan.feature() == ColumnPlan.Feature.DOCK).isPresent()) { + onQuay++; + } + if (engine.surfaceHeightAt(arrive[0], arrive[1]) > SEA) { + onLand++; + } + } + } + assertTrue(tested > 0, "No islands to test"); + assertTrue(onQuay > 0, "No arrival point lands on a quay - has the dock or arrival distance moved?"); + // The deck sits exactly at the arrival height, which is what turned a + // rare unlucky bearing into a death + assertEquals(SEA + 1, SEA + GalaxyEngine.DOCK_RISE); + // And confirm the thing that is NOT to blame, so nobody re-fixes it + assertEquals(0, onLand, "Natural land now reaches the arrival ring too - widen the search"); + } + + @Test + void testSearchOffsetsAreOrderedNearestFirst() { + // The ordering is the guarantee that an arrival lands as close to its + // intended spot as the terrain allows + List offsets = SeaArrival.searchOffsets(32, 4); + assertEquals(0, offsets.get(0)[0]); + assertEquals(0, offsets.get(0)[1]); + int previous = -1; + for (int[] offset : offsets) { + int distance = offset[0] * offset[0] + offset[1] * offset[1]; + assertTrue(distance >= previous, "Search order jumped backwards"); + previous = distance; + } + // Every candidate is inside the search radius + offsets.forEach(o -> assertTrue(o[0] * o[0] + o[1] * o[1] <= 32 * 32)); + assertTrue(offsets.size() > 100, "Too few candidates to find water: " + offsets.size()); + } + + @Test + void testSearchIsWideEnoughToClearAnIsland() { + // A headland can reach well past the arrival ring, so the search has to + // be able to get back out to open water from inside one + List offsets = SeaArrival.searchOffsets(160, 4); + int furthest = offsets.stream().mapToInt(o -> o[0] * o[0] + o[1] * o[1]).max().orElse(0); + assertTrue(Math.sqrt(furthest) >= 150, "Search radius is smaller than an island"); + } +} From 77a5eeba0cb9f519de826a0e698800086637bb6e Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 08:35:29 -0700 Subject: [PATCH 058/112] Low fuel warning, and show the security band on the boss bar Two playtest asks, both about the player not being told something. Running dry at a port is only fixable while the sailor is still standing next to the fuel, so the check happens there: fuel aboard against the cheapest charted route out, since affording the far island is irrelevant if you cannot afford the near one. Ben's caveat drove the design - "for kids, they won't read it if it isn't obvious" - so it is told three times in three different lifetimes: an action bar that repeats every 8s while ashore and short, a chat line once per port that stays in the log with the shortfall, and the market dialog relabelling whichever button actually sells fuel to "BUY FUEL HERE" (the outfitter normally, the buy page where the island's own catalog stocks fuel). Nothing charted means no warning - that is not a fuel problem. The boss bar now reads name | band | standing | dock, with the band coloured in the locale, blue Safe through bold dark-red ANARCHIC. The bar colour already tracked the band but a colour with no label is a puzzle, and the "Clean" already there is the player's standing, which is easy to misread as being about the island. The same coloured band now appears on chart holograms and the market subtitle. 205 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 23 +++++ docs/PROGRESS.md | 35 +++++++ .../world/bentobox/tradewinds/Settings.java | 23 +++++ .../world/bentobox/tradewinds/TradeWinds.java | 8 ++ .../tradewinds/economy/TradeDialog.java | 47 +++++++-- .../tradewinds/galaxy/SecurityBand.java | 11 +++ .../tradewinds/tasks/FuelWarningTask.java | 99 +++++++++++++++++++ .../tradewinds/tasks/NavigationBarTask.java | 12 ++- .../tradewinds/travel/ChartHolograms.java | 2 +- .../tradewinds/travel/FuelWarning.java | 62 ++++++++++++ src/main/resources/config.yml | 12 +++ src/main/resources/locales/en-US.yml | 20 +++- .../tradewinds/travel/FuelWarningTest.java | 71 +++++++++++++ 13 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/tasks/FuelWarningTask.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/FuelWarning.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/FuelWarningTest.java diff --git a/TESTING.md b/TESTING.md index 38676a7..58a7174 100644 --- a/TESTING.md +++ b/TESTING.md @@ -814,3 +814,26 @@ out. Set `travel.warp.failure-chance: 1.0` temporarily to test this repeatedly. again on the same player to clear it. Check the console/chat feedback both ways. - [ ] After a rigged failure, the flag is gone: the next warp behaves normally. - [ ] Interstice arrivals also land on open water, not inside the interstice floor. + +## Low fuel warning, and reading the boss bar + +- [ ] Stand at a port with less fuel than the cheapest charted warp costs: a **LOW + FUEL** action bar appears and **repeats** every 8 seconds while you are there. +- [ ] A **chat line** also arrives, once per port visit, saying how much more fuel + you need. It stays in the chat log — this is the one a child will still find + after looking away. +- [ ] Open the market: the body says LOW FUEL, and the button that actually sells + fuel is relabelled **"BUY FUEL HERE"** — the Outfitter normally, or the Buy + Goods button at islands whose own catalog stocks fuel. +- [ ] Buy fuel: all three warnings stop. +- [ ] With nothing charted to warp to, there is **no** warning — being unable to + warp is not a fuel problem then. +- [ ] Out at sea (not at a port), no warning. It is only raised where it is fixable. +- [ ] `travel.fuel-warning.margin: 1.5` warns while you can still just about leave; + `enabled: false` removes all of it including the dialog highlight. + +- [ ] The navigation boss bar now reads **name | band | standing | dock distance**, + with the band coloured — blue Safe, green Policed, yellow Frontier, red + Lawless, bold dark-red ANARCHIC. Sail into each and confirm you can tell at a + glance what kind of water you are in. +- [ ] The same coloured band appears on the chart holograms and the market subtitle. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d50bf70..5e6743e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,41 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Low fuel warning, and the band on the boss bar (2026-08-01) + +Two playtest asks, both about the player not being told something they needed. + +**Low fuel.** Running dry at a port is not a soft failure - the only way onward +is rowing - and it is only fixable while the sailor is still standing next to +the fuel. So the check happens at the port: fuel aboard against the *cheapest* +charted route out, since affording the far island is irrelevant if you cannot +afford the near one. + +Ben's own caveat drove the design: "for kids, they won't read it if it isn't +obvious". So it is told three times, in three different lifetimes: +- the **action bar** repeats every 8s while ashore and short (a one-shot would + be missed by exactly the players this is for), +- a **chat line** fires once per port and *stays* in the log, with how much more + fuel is needed, +- the **market dialog** relabels whichever button actually sells fuel to + "BUY FUEL HERE" - the outfitter normally, or the buy page at islands whose own + catalog stocks fuel, since the outfitter only carries charcoal when the trade + catalog does not. + +No warning when nothing is charted: being unable to warp is not a fuel problem +then, and saying so would be a lie. + +**The band on the boss bar.** "I arrived in an anarchy and I couldn't remember +what kind of island it was, and couldn't work out how to tell." The bar colour +already tracked the band, but a colour with no label is a puzzle. The bar now +reads *name | band | standing | dock*, with the band coloured in the locale +(blue Safe through bold dark-red ANARCHIC) - and the same coloured name now +appears on chart holograms and the market subtitle. Worth noting the bar's +"Clean" was the *player's standing* all along, which is easy to misread as +something about the island. + +205 tests green. + ## "Suffocated in a wall": warp arrivals landing inside the quay (2026-08-01) Ben's death screen: *"BoxManager suffocated in a wall whilst fighting Ghast"*, diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index df8e342..f059257 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -418,6 +418,23 @@ public double bountyFor(Crime crime) { @ConfigEntry(path = "travel.warp.damage") private double warpDamage = 2.0; + @ConfigComment("Warn a sailor standing at a port when they cannot afford to warp anywhere from") + @ConfigComment("it. Running dry at a port is not a soft failure - the only way onward is rowing -") + @ConfigComment("and it is only fixable while they are still standing next to the fuel.") + @ConfigEntry(path = "travel.fuel-warning.enabled") + private boolean fuelWarningEnabled = true; + + @ConfigComment("Seconds between repeats of the low-fuel action bar while ashore. It repeats") + @ConfigComment("because an action bar fades and a one-shot is missed by exactly the players this") + @ConfigComment("is for; the chat line and the highlighted shop button are the other two tellings.") + @ConfigEntry(path = "travel.fuel-warning.repeat-seconds") + private int fuelWarningSeconds = 8; + + @ConfigComment("Warn when fuel is below this multiple of the cheapest route out. 1.0 warns only") + @ConfigComment("when genuinely stuck; raise it to warn with something still in reserve.") + @ConfigEntry(path = "travel.fuel-warning.margin") + private double fuelWarningMargin = 1.0; + @ConfigComment("Fuel unit value per material. Fuel is consumed from the hold only:") @ConfigComment("the chest boat's inventory and trading bundles - never loose pockets.") @ConfigComment("Lava buckets leave their empty bucket behind. Non-stackable but potent: deliberate tension.") @@ -2768,6 +2785,12 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWarpBlindnessSeconds(int warpBlindnessSeconds) { this.warpBlindnessSeconds = warpBlindnessSeconds; } public double getWarpDamage() { return warpDamage; } public void setWarpDamage(double warpDamage) { this.warpDamage = warpDamage; } + public boolean isFuelWarningEnabled() { return fuelWarningEnabled; } + public void setFuelWarningEnabled(boolean v) { this.fuelWarningEnabled = v; } + public int getFuelWarningSeconds() { return fuelWarningSeconds; } + public void setFuelWarningSeconds(int v) { this.fuelWarningSeconds = v; } + public double getFuelWarningMargin() { return fuelWarningMargin; } + public void setFuelWarningMargin(double v) { this.fuelWarningMargin = v; } public Map getFuelValues() { return fuelValues; } public void setFuelValues(Map fuelValues) { this.fuelValues = fuelValues; } public Map getEdgeOverrides() { return edgeOverrides; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index cd82809..a8cd330 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -54,6 +54,7 @@ import world.bentobox.tradewinds.galaxy.RouteGraph; import world.bentobox.tradewinds.listeners.IntersticePortalListener; import world.bentobox.tradewinds.listeners.ResidentProtectionListener; +import world.bentobox.tradewinds.tasks.FuelWarningTask; import world.bentobox.tradewinds.tasks.NavigationBarTask; import world.bentobox.tradewinds.tasks.ResidentAuditTask; import world.bentobox.tradewinds.travel.BoatPickupListener; @@ -116,6 +117,7 @@ public class TradeWinds extends GameModeAddon { private NamespacedKey policeKey; private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; + private @Nullable FuelWarningTask fuelWarningTask; /** * This addon uses the new chunk generation API for the sea bottom @@ -292,6 +294,9 @@ public void onEnable() { // Navigation boss bar: island, standing, distance to dock navigationBarTask = new NavigationBarTask(this); navigationBarTask.start(); + // "You cannot afford to leave" - said at the port, where it is fixable + fuelWarningTask = new FuelWarningTask(this); + fuelWarningTask.start(); // Spawn (and bed-less respawn) is the spawn island's market plaza. // The island itself is adopted in allLoaded() - see below. registerListener(new world.bentobox.tradewinds.listeners.SpawnRespawnListener(this)); @@ -305,6 +310,9 @@ public void onDisable() { if (navigationBarTask != null) { navigationBarTask.stop(); } + if (fuelWarningTask != null) { + fuelWarningTask.stop(); + } if (intersticeService != null) { intersticeService.stop(); } diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 2c949af..5f6f614 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -20,6 +20,7 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.travel.FuelWarning; /** * The market dialogs: a main menu (sell / buy / shipwright), a sell page @@ -55,23 +56,57 @@ public void openMain(Player player, IslandSpec spec) { User.getInstance(player).sendMessage("tradewinds.trade.barred"); return; } + // Too poor in fuel to warp anywhere from here? Then the most useful + // thing this screen can do is point at where the fuel is sold. An + // action bar fades; a button that says "buy fuel here" does not. + boolean lowFuel = isLowOnFuel(player, spec); + boolean fuelInTradeCatalog = lowFuel && sellsFuel(addon.getMarketService().saleCatalog(spec)); + List buttons = new ArrayList<>(); // No sell button when the hold has nothing this island pays for if (!sellOffers(player, spec).isEmpty()) { buttons.add(button(player, "market.sell", "market.sell-tooltip", () -> openSell(player, spec))); } if (!addon.getMarketService().saleCatalog(spec).isEmpty()) { - buttons.add(button(player, "market.buy", "market.buy-tooltip", + buttons.add(button(player, fuelInTradeCatalog ? "market.buy-fuel" : "market.buy", + fuelInTradeCatalog ? "market.buy-fuel-tooltip" : "market.buy-tooltip", () -> openBuy(player, spec, addon.getMarketService().saleCatalog(spec), "buying"))); } - buttons.add(button(player, "market.outfitter", "market.outfitter-tooltip", + // The outfitter only stocks charcoal when the trade catalog has no fuel + // of its own, so highlight whichever one can actually help + boolean fuelAtOutfitter = lowFuel && !fuelInTradeCatalog; + buttons.add(button(player, fuelAtOutfitter ? "market.outfitter-fuel" : "market.outfitter", + fuelAtOutfitter ? "market.outfitter-fuel-tooltip" : "market.outfitter-tooltip", () -> openOutfitter(player, spec))); buttons.add(button(player, "market.shipwright", "market.shipwright-tooltip", () -> openShipwright(player, spec))); - show(player, ui(player, "market.title", "[name]", spec.name()), - List.of(ui(player, "market.subtitle", "[type]", spec.type().name(), "[band]", - spec.band().getDisplayName()), statusLine(player)), - buttons, closeButton(player), 1); + List body = new ArrayList<>(List.of( + ui(player, "market.subtitle", "[type]", spec.type().name(), "[band]", + User.getInstance(player).getTranslation(spec.band().getLocaleKey())), + statusLine(player))); + if (lowFuel) { + body.add(ui(player, "market.low-fuel", NO_VARS)); + } + show(player, ui(player, "market.title", "[name]", spec.name()), body, buttons, closeButton(player), 1); + } + + /** + * Whether the player cannot afford to warp anywhere from this island. + */ + private boolean isLowOnFuel(Player player, IslandSpec spec) { + if (!addon.getSettings().isFuelWarningEnabled()) { + return false; + } + double fuel = addon.getFuelService().holdFuel(player); + int cheapest = FuelWarning.cheapestRoute(addon.getWarpService().destinations(player, spec, fuel)); + return FuelWarning.isLow(fuel, cheapest, addon.getSettings().getFuelWarningMargin()); + } + + /** + * Whether a catalog contains anything that burns. + */ + private boolean sellsFuel(List catalog) { + return catalog.stream().anyMatch(m -> addon.getSettings().getFuelValues().getOrDefault(m.name(), 0.0) > 0); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/SecurityBand.java b/src/main/java/world/bentobox/tradewinds/galaxy/SecurityBand.java index d83fd8f..bdde9a2 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/SecurityBand.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/SecurityBand.java @@ -24,6 +24,17 @@ public String getDisplayName() { return displayName; } + /** + * The locale key for this band's name. Bands are coloured in the locale so + * a sailor can read where they are at a glance - arriving in anarchic water + * and having no way to tell is how people lose cargo. + * + * @return locale key + */ + public String getLocaleKey() { + return "tradewinds.band." + name().toLowerCase(java.util.Locale.ENGLISH); + } + /** * @return true if PvP is live on this island's space */ diff --git a/src/main/java/world/bentobox/tradewinds/tasks/FuelWarningTask.java b/src/main/java/world/bentobox/tradewinds/tasks/FuelWarningTask.java new file mode 100644 index 0000000..26a516f --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/tasks/FuelWarningTask.java @@ -0,0 +1,99 @@ +package world.bentobox.tradewinds.tasks; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.travel.FuelWarning; + +/** + * Tells a sailor standing in a market that they cannot afford to leave it. + *

+ * Two channels on purpose. The action bar repeats while they are ashore and + * short, because an action bar is easy to miss and a one-shot would be missed + * by exactly the players this is for. A chat line goes out once per visit, + * because chat stays - a child who looks away for ten seconds can + * still find out what happened. The market dialog carries the third telling + * (a highlighted "buy fuel here" button), so the warning is never more than + * one screen from the fix. + * + * @author tastybento + */ +public class FuelWarningTask implements Runnable { + + private final TradeWinds addon; + /** Player -> the island cell they were last warned at, so chat fires once. */ + private final Map warnedAt = new ConcurrentHashMap<>(); + private BukkitTask task; + + public FuelWarningTask(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + long period = Math.max(1, addon.getSettings().getFuelWarningSeconds()) * 20L; + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this, period, period); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + } + + @Override + public void run() { + if (!addon.getSettings().isFuelWarningEnabled() || addon.getOverWorld() == null) { + return; + } + for (Player player : addon.getOverWorld().getPlayers()) { + Optional port = portAt(player); + if (port.isEmpty()) { + warnedAt.remove(player.getUniqueId()); + continue; + } + check(player, port.get()); + } + } + + private void check(Player player, IslandSpec port) { + double fuel = addon.getFuelService().holdFuel(player); + int cheapest = FuelWarning.cheapestRoute(addon.getWarpService().destinations(player, port, fuel)); + if (!FuelWarning.isLow(fuel, cheapest, addon.getSettings().getFuelWarningMargin())) { + warnedAt.remove(player.getUniqueId()); + return; + } + User user = User.getInstance(player); + String key = port.cellX() + "," + port.cellZ(); + int short_ = FuelWarning.shortfall(fuel, cheapest); + // Repeats while they stand there - an action bar fades, the problem does not + user.sendMessage("tradewinds.fuel.low-actionbar", "[fuel]", String.valueOf((int) fuel), "[needed]", + String.valueOf(cheapest)); + if (!key.equals(warnedAt.get(player.getUniqueId()))) { + // Once per port, in chat, where it stays put + warnedAt.put(player.getUniqueId(), key); + user.sendMessage("tradewinds.fuel.low-chat", "[fuel]", String.valueOf((int) fuel), "[needed]", + String.valueOf(cheapest), TextVariables.NUMBER, String.valueOf(short_)); + } + } + + /** + * The trading island whose space the player is standing in, if any. + */ + private Optional portAt(Player player) { + int x = player.getLocation().getBlockX(); + int z = player.getLocation().getBlockZ(); + int range = addon.getSettings().getIslandProtectionRange(); + return addon.getGalaxyEngine(addon.getOverWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(s -> s.distanceSquared(x, z) <= (long) range * range).findFirst(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java index 3729406..f4d972a 100644 --- a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java +++ b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java @@ -20,9 +20,14 @@ /** * The navigation boss bar: while in an island's waters it shows the island - * name, the player's standing, and the distance to the dock - steer toward the - * dock and watch the number fall. Bar color tracks the security band, and the - * bar fills as you close on the pier. + * name, its security band, the player's standing, and the distance to + * the dock - steer toward the dock and watch the number fall. Bar color tracks + * the band too, and the bar fills as you close on the pier. + *

+ * The band is spelled out because the bar colour alone was not enough in play: + * "I arrived in an anarchy and I couldn't remember what kind of island it was, + * and couldn't work out how to tell." Whether the law is watching is the single + * most consequential fact about where you are. * * @author tastybento */ @@ -107,6 +112,7 @@ private void show(Player player, Reading reading) { User user = User.getInstance(player); Component name = user.getTranslationAsComponent("tradewinds.hud.navigation", "[name]", reading.island().name(), + "[band]", user.getTranslation(reading.island().band().getLocaleKey()), "[standing]", addon.getPlayerStanding(user, player.getUniqueId()), "[distance]", String.valueOf(reading.dockDistance())); BossBar bar = bars.computeIfAbsent(player.getUniqueId(), diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index 7c32d4f..cec3f79 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -138,7 +138,7 @@ private Component label(Player player, Marker marker) { return User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.island", "[name]", spec.name(), "[type]", spec.type().name(), - "[band]", spec.band().getDisplayName(), + "[band]", User.getInstance(player).getTranslation(spec.band().getLocaleKey()), "[distance]", String.valueOf(marker.distance())); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/FuelWarning.java b/src/main/java/world/bentobox/tradewinds/travel/FuelWarning.java new file mode 100644 index 0000000..3642c4a --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/FuelWarning.java @@ -0,0 +1,62 @@ +package world.bentobox.tradewinds.travel; + +import java.util.List; + +import world.bentobox.tradewinds.travel.WarpService.Destination; + +/** + * Whether a sailor can afford to leave the port they are standing in. + *

+ * Running out of fuel at a port is not a soft failure - the only way onward is + * rowing, and a player who has not noticed will simply be stuck wondering why + * the warp dialog refuses them. The check is deliberately made at the port, + * where fuel is for sale and the problem is still fixable, rather than out at + * the border where it is not. + *

+ * Pure arithmetic, so the rule is testable without a server. + * + * @author tastybento + */ +public final class FuelWarning { + + private FuelWarning() { + // Static use only + } + + /** + * The cheapest way out of here, in fuel units. + * + * @param destinations the warp destinations offered at this island + * @return the lowest fuel cost, or -1 if there is nowhere to go + */ + public static int cheapestRoute(List destinations) { + return destinations.stream().mapToInt(Destination::fuelCost).min().orElse(-1); + } + + /** + * Whether a sailor is too low on fuel to warp anywhere from here. + * + * @param fuelAboard fuel units in the hold + * @param cheapestRoute the cheapest route out, or -1 if there is none + * @param margin multiplier on the cheapest route before warning; 1.0 warns + * only when genuinely stuck, higher warns with something in reserve + * @return true if they should be told + */ + public static boolean isLow(double fuelAboard, int cheapestRoute, double margin) { + if (cheapestRoute < 0) { + return false; // Nowhere charted to warp to: fuel is not the problem + } + return fuelAboard < cheapestRoute * Math.max(1.0, margin); + } + + /** + * How much more fuel is needed, rounded up - a number a player can act on. + * + * @param fuelAboard fuel units in the hold + * @param cheapestRoute the cheapest route out + * @return units short, never negative + */ + public static int shortfall(double fuelAboard, int cheapestRoute) { + return (int) Math.max(0, Math.ceil(cheapestRoute - fuelAboard)); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 8285a76..89bb138 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -92,6 +92,18 @@ travel: damage: 2.0 # Per-edge fuel cost overrides ('cx,cz>cx,cz' smaller cell first -> absolute fuel units). edge-overrides: {} + fuel-warning: + # Warn a sailor standing at a port when they cannot afford to warp anywhere from + # it. Running dry at a port is not a soft failure - the only way onward is rowing + # - and it is only fixable while they are still standing next to the fuel. + enabled: true + # Seconds between repeats of the action bar while ashore and short. It repeats + # because an action bar fades and a one-shot is missed by exactly the players + # this is for; the chat line and the highlighted shop button are the other two. + repeat-seconds: 8 + # Warn when fuel is below this multiple of the cheapest route out. 1.0 warns only + # when genuinely stuck; raise it to warn with something still in reserve. + margin: 1.0 # Fuel unit value per material. Fuel is consumed from the hold only: the chest # boat's inventory and trading bundles - never loose pockets. fuel-values: diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 7407ae9..97e4adf 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -101,14 +101,23 @@ tradewinds: jettisoned: "Your cargo goes over the side. The patrol turns back - nothing to find." escaped: "Open water. [name] loses you - but its harbourmaster will not forget." caught: "Boarded. [number] items seized, [amount] in fines, and [name] has your name." + band: + safe: "Safe" + policed: "Policed" + frontier: "Frontier" + lawless: "Lawless" + anarchic: "ANARCHIC" + fuel: + low-actionbar: "[actionbar]LOW FUEL - [fuel]/[needed] to warp out. Buy fuel at the market." + low-chat: "Low fuel at this port. You have [fuel] units; the cheapest warp from here needs [needed]. Buy at least [number] more from the market before you leave, or you will be rowing." hud: - navigation: "[name] | [standing] | Dock [distance]m" + navigation: "[name] | [band] | [standing] | Dock [distance]m" hologram: - island: "[name][type], [band][distance]m" + island: "[name][type], [band][distance]m" ui: market: title: "[name] Market" - subtitle: "[type], [band]" + subtitle: "[type], [band]" status: "Balance: $[balance] | Hold space: ~[space] items" sell: "Sell cargo" sell-tooltip: "Sell your hold's goods here" @@ -116,6 +125,11 @@ tradewinds: buy-tooltip: "Buy this island's trade goods into your hold" outfitter: "Outfitter" outfitter-tooltip: "Food, fuel and gear - straight to your pack" + outfitter-fuel: "BUY FUEL HERE - Outfitter" + outfitter-fuel-tooltip: "You cannot warp anywhere without more fuel. Buy charcoal here." + buy-fuel: "BUY FUEL HERE - Buy goods" + buy-fuel-tooltip: "You cannot warp anywhere without more fuel. This island sells it." + low-fuel: "LOW FUEL - not enough to warp anywhere from here." shipwright: "Shipwright" shipwright-tooltip: "Boats, chest boats and cargo expanders" back: "< Back" diff --git a/src/test/java/world/bentobox/tradewinds/travel/FuelWarningTest.java b/src/test/java/world/bentobox/tradewinds/travel/FuelWarningTest.java new file mode 100644 index 0000000..5098553 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/FuelWarningTest.java @@ -0,0 +1,71 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.IslandType; +import world.bentobox.tradewinds.galaxy.SecurityBand; +import world.bentobox.tradewinds.travel.WarpService.Destination; + +/** + * Headless tests of "can this sailor afford to leave?". + * + * @author tastybento + */ +class FuelWarningTest { + + private Destination dest(int cost) { + IslandSpec spec = new IslandSpec(1, 1, 100, 100, IslandType.FISHING, SecurityBand.SAFE, + "minecraft:plains", "Somewhere"); + return new Destination(spec, cost, true); + } + + @Test + void testCheapestRouteIsTheOneThatMatters() { + // Being able to afford the far island is irrelevant; being unable to + // afford the nearest one is what strands you + assertEquals(12, FuelWarning.cheapestRoute(List.of(dest(40), dest(12), dest(97)))); + assertEquals(-1, FuelWarning.cheapestRoute(List.of())); + } + + @Test + void testWarnsOnlyWhenGenuinelyShort() { + assertTrue(FuelWarning.isLow(0, 12, 1.0)); + assertTrue(FuelWarning.isLow(11.9, 12, 1.0)); + assertFalse(FuelWarning.isLow(12, 12, 1.0)); + assertFalse(FuelWarning.isLow(500, 12, 1.0)); + } + + @Test + void testNowhereToGoIsNotAFuelProblem() { + // A sailor with nothing charted is not stranded by fuel, and telling + // them to buy some would be a lie + assertFalse(FuelWarning.isLow(0, -1, 1.0)); + assertFalse(FuelWarning.isLow(0, -1, 5.0)); + } + + @Test + void testMarginWarnsWithSomethingInReserve() { + // At 1.5 a sailor is told while they can still just about leave + assertTrue(FuelWarning.isLow(15, 12, 1.5)); + assertFalse(FuelWarning.isLow(18, 12, 1.5)); + // A margin below 1 must not silence a genuine shortfall + assertTrue(FuelWarning.isLow(5, 12, 0.1)); + } + + @Test + void testShortfallIsActionable() { + // Round up: telling someone they need 3.2 charcoal helps nobody + assertEquals(12, FuelWarning.shortfall(0, 12)); + assertEquals(4, FuelWarning.shortfall(8.1, 12)); + assertEquals(1, FuelWarning.shortfall(11.5, 12)); + assertEquals(0, FuelWarning.shortfall(12, 12)); + assertEquals(0, FuelWarning.shortfall(99, 12)); + } +} From a6a953395d34f3fb08ad9016a6cae1af52d9e40a Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 08:38:08 -0700 Subject: [PATCH 059/112] Remove /tw spawn: it had become a free warp home Spotted in playtest. The exploit only appeared when two good decisions met: spawn became a real trading post so new players would trade there, and the free teleport that was harmless when spawn was an empty islet turned into a free ride to a market from anywhere in the galaxy. Unregistered rather than permission-gated - a command that exists and is denied still shows up and still invites asking for it. TWSpawnCommand stays in the tree for whenever it is wanted again. new-player-action and default-action both pointed at spawn, so bare /tw would have fallen through to a non-existent sub-command; both now default to help. Death still respawns at the spawn plaza - you paid for that trip with your cargo - and /tw restart still returns a destitute player there, capped. 205 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 11 +++++++++ docs/PROGRESS.md | 24 +++++++++++++++++++ .../world/bentobox/tradewinds/Settings.java | 8 +++---- .../world/bentobox/tradewinds/TradeWinds.java | 7 ++++-- src/main/resources/addon.yml | 7 ++++-- src/main/resources/config.yml | 7 +++--- .../bentobox/tradewinds/SettingsTest.java | 7 +++--- .../bentobox/tradewinds/TradeWindsTest.java | 11 ++++++--- 8 files changed, 65 insertions(+), 17 deletions(-) diff --git a/TESTING.md b/TESTING.md index 58a7174..f5c9121 100644 --- a/TESTING.md +++ b/TESTING.md @@ -837,3 +837,14 @@ out. Set `travel.warp.failure-chance: 1.0` temporarily to test this repeatedly. Lawless, bold dark-red ANARCHIC. Sail into each and confirm you can tell at a glance what kind of water you are in. - [ ] The same coloured band appears on the chart holograms and the market subtitle. + +## /tw spawn removed (free warp home) + +- [ ] `/tw spawn` is **gone** - it is not in `/tw help` and running it is unknown. + Now that spawn is a working trading post it was a free warp back to a market + from anywhere, which undercuts the whole point of travelling. +- [ ] Bare `/tw` shows the command list rather than teleporting anywhere. +- [ ] Dying still respawns you at the spawn plaza (that is not a cheat - you lost + your cargo to get there). +- [ ] `/tw restart` still returns a destitute player to spawn with a fresh kit, + capped by `player.max-restarts`. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5e6743e..f4b07bd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,30 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## /tw spawn removed: it had become a free warp home (2026-08-01) + +Ben spotted it: "Now that Spawn is a trading post, which is a good thing... +the /tw spawn command is a free way to warp back there!" Exactly right, and it +is the kind of exploit that only appears when two good decisions meet - spawn +became a real port so new players would trade there, and the free teleport that +was harmless when spawn was an empty islet turned into a free ride to a market +from anywhere in the galaxy. + +Unregistered rather than permission-gated, as asked - a command that exists and +is denied still shows up and still invites "can I have this?". `TWSpawnCommand` +stays in the tree for whenever it is wanted again. + +The catch: `new-player-action` and `default-action` both pointed at `spawn`, so +bare `/tw` would have fallen through to a non-existent sub-command. Both now +default to `help`, which is the most useful thing to hand someone anyway. The +live server config had the old values too - stored config beats changed +defaults, as always. + +Death still respawns at the spawn plaza (you paid for that trip with your +cargo), and `/tw restart` still returns a destitute player there, capped. + +205 tests green. + ## Low fuel warning, and the band on the boss bar (2026-08-01) Two playtest asks, both about the player not being told something they needed. diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index f059257..733d414 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -48,15 +48,15 @@ public class Settings implements WorldSettings { @ConfigComment("The default action for new player command call.") @ConfigComment("Sub-command of main player command that will be run on first player command call.") - @ConfigComment("TradeWinds players do not start with an island, so the default is 'spawn'.") + @ConfigComment("'spawn' is deliberately NOT registered as a player command - it would be a free") + @ConfigComment("warp back to the spawn trading post - so bare /tw shows the command list.") @ConfigEntry(path = "tradewinds.command.new-player-action") - private String defaultNewPlayerAction = "spawn"; + private String defaultNewPlayerAction = "help"; @ConfigComment("The default action for player command.") @ConfigComment("Sub-command of main player command that will be run on each player command call.") - @ConfigComment("TradeWinds players do not start with an island, so the default is 'spawn'.") @ConfigEntry(path = "tradewinds.command.default-action") - private String defaultPlayerAction = "spawn"; + private String defaultPlayerAction = "help"; /* GALAXY */ @ConfigComment("The galaxy seed. Every island position, type, security band, biome, name and") diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index a8cd330..ac607ec 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -33,7 +33,6 @@ import world.bentobox.tradewinds.commands.TWChartCommand; import world.bentobox.tradewinds.commands.TWFineCommand; import world.bentobox.tradewinds.commands.TWRestartCommand; -import world.bentobox.tradewinds.commands.TWSpawnCommand; import world.bentobox.tradewinds.commands.TWStarChartCommand; import world.bentobox.tradewinds.commands.TWTradeCommand; import world.bentobox.tradewinds.commands.TWWarpCommand; @@ -168,7 +167,11 @@ public void setup() { setDescription("tradewinds.commands.help.description"); setOnlyPlayer(true); setPermission("island"); - new TWSpawnCommand(this); + // NOTE: TWSpawnCommand is deliberately NOT registered. Now that + // spawn is a working trading post, /tw spawn would be a free + // warp back to a market from anywhere - travel is the game. + // Death still respawns there (SpawnRespawnListener), and + // /tw restart still returns a destitute player there. new TWWarpCommand(this); new TWChartCommand(this); new TWStarChartCommand(this); diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 37f1902..d3b4fe4 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -13,8 +13,11 @@ permissions: description: Allow use of the TradeWinds player command default: true tradewinds.island.spawn: - description: Allow teleporting to the ocean spawn - default: true + description: > + Unused: the spawn command is not registered. Spawn is a working trading + post, so /tw spawn would be a free warp back to a market from anywhere. + Kept so servers that re-enable the command have the node ready. + default: false tradewinds.island.warp: description: > Shortcut: open the warp dialog from anywhere in island waters. Off by diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 89bb138..47e8e21 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,10 +6,11 @@ tradewinds: # The admin command. admin: twadmin # The default action for new player command call. - # TradeWinds players do not start with an island, so the default is 'spawn'. - new-player-action: spawn + # 'spawn' is deliberately NOT registered as a player command - it would be a free + # warp back to the spawn trading post - so bare /tw shows the command list. + new-player-action: help # The default action for player command. - default-action: spawn + default-action: help galaxy: # The galaxy seed. Every island position, type, security band, biome, name and # route cost derives deterministically from this one number - share it and another diff --git a/src/test/java/world/bentobox/tradewinds/SettingsTest.java b/src/test/java/world/bentobox/tradewinds/SettingsTest.java index f4c3d16..05682f4 100644 --- a/src/test/java/world/bentobox/tradewinds/SettingsTest.java +++ b/src/test/java/world/bentobox/tradewinds/SettingsTest.java @@ -29,9 +29,10 @@ public void setUp() throws Exception { void testCommands() { assertEquals("tw tradewinds", settings.getPlayerCommandAliases()); assertEquals("twadmin", settings.getAdminCommandAliases()); - // No starting island: both default actions teleport to spawn - assertEquals("spawn", settings.getDefaultNewPlayerAction()); - assertEquals("spawn", settings.getDefaultPlayerAction()); + // 'spawn' is not a registered player command - it would be a free warp + // back to the spawn trading post - so bare /tw lists the commands + assertEquals("help", settings.getDefaultNewPlayerAction()); + assertEquals("help", settings.getDefaultPlayerAction()); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java index 7dc97b6..578f511 100644 --- a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java +++ b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java @@ -139,11 +139,16 @@ void testOnLoad() { assertTrue(addon.getPlayerCommand().isPresent()); assertTrue(addon.getAdminCommand().isPresent()); assertNotNull(addon.getBiomeProvider()); - // Players can reach spawn but CANNOT create free islands - player - // islands are purchased (Stage 7) - assertTrue(addon.getPlayerCommand().get().getSubCommand("spawn").isPresent()); + // Players CANNOT create free islands - player islands are purchased + // (Stage 7) - and cannot command their way home either: spawn is a + // working trading post, so /tw spawn would be a free warp to a market + // from anywhere. Travel is the game. + assertTrue(addon.getPlayerCommand().get().getSubCommand("spawn").isEmpty()); assertTrue(addon.getPlayerCommand().get().getSubCommand("create").isEmpty()); assertTrue(addon.getPlayerCommand().get().getSubCommand("reset").isEmpty()); + // The commands that remain are the ones that require being somewhere + assertTrue(addon.getPlayerCommand().get().getSubCommand("warp").isPresent()); + assertTrue(addon.getPlayerCommand().get().getSubCommand("chart").isPresent()); // Admin discovery commands assertTrue(addon.getAdminCommand().get().getSubCommand("islands").isPresent()); assertTrue(addon.getAdminCommand().get().getSubCommand("tpisland").isPresent()); From c6259639f7c543a01828cdcad01d97b07f57c800 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 08:46:23 -0700 Subject: [PATCH 060/112] Make /tw go a door into the ocean rather than a teleport Deleting /tw spawn closed a free warp to a market, but it also closed the front door: a new player, or anyone standing in another game mode's world, had no way into the ocean at all. So it is a door with two rules. It refuses while you are already at sea - that is the exploit, and the whole of it. And coming in from outside returns you to the water you left, not to spawn. The second rule matters more than it looks: this server runs several game modes, so a spawn-anchored door would have re-opened the same free ride through the side - /acid, then /tw, and you are standing in a market. Only a sailor who has never set out starts at the spawn port. SeaPositionTracker records the spot on quit and on any teleport out of a TradeWinds world. It uses PlayerTeleportEvent rather than PlayerChangedWorldEvent because by the time the latter fires the player has already moved, so their location is the destination - the one position that is no use. Labelled go, with spawn and sail as aliases, and both default actions point at it so bare /tw is the natural entry. 205 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 26 +++-- docs/PROGRESS.md | 31 ++++-- .../world/bentobox/tradewinds/Settings.java | 9 +- .../world/bentobox/tradewinds/TradeWinds.java | 20 +++- .../tradewinds/commands/TWSpawnCommand.java | 41 +++++-- .../tradewinds/dataobjects/TWPlayerData.java | 15 +++ .../tradewinds/travel/SeaPositionTracker.java | 105 ++++++++++++++++++ src/main/resources/addon.yml | 8 +- src/main/resources/config.yml | 9 +- src/main/resources/locales/en-US.yml | 4 +- .../bentobox/tradewinds/SettingsTest.java | 7 +- .../bentobox/tradewinds/TradeWindsTest.java | 9 +- 12 files changed, 228 insertions(+), 56 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/travel/SeaPositionTracker.java diff --git a/TESTING.md b/TESTING.md index f5c9121..d6a369b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -838,13 +838,23 @@ out. Set `travel.warp.failure-chance: 1.0` temporarily to test this repeatedly. glance what kind of water you are in. - [ ] The same coloured band appears on the chart holograms and the market subtitle. -## /tw spawn removed (free warp home) - -- [ ] `/tw spawn` is **gone** - it is not in `/tw help` and running it is unknown. - Now that spawn is a working trading post it was a free warp back to a market - from anywhere, which undercuts the whole point of travelling. -- [ ] Bare `/tw` shows the command list rather than teleporting anywhere. -- [ ] Dying still respawns you at the spawn plaza (that is not a cheat - you lost - your cargo to get there). +## /tw go - the door into the ocean, and nothing more + +`/tw spawn` was a free warp back to a market once spawn became a working trading +post. But deleting it locked new players out entirely, so it is now a door with +two rules. + +- [ ] **From another world** (or on first ever join): `/tw` or `/tw go` puts you + into the ocean. A brand new player arrives at the spawn port with a starter kit. +- [ ] **A returning player arrives where they LEFT the ocean**, not at spawn. Sail + a long way out, `/mv tp world` (or `/acid`), then `/tw` - you should be back + in the same stretch of water, not at a market. +- [ ] **Already at sea**: `/tw go` refuses ("You are already at sea..."). This is the + exploit that had to close - there is no commanding your way to a market. +- [ ] Log out at sea, log back in: you are where you left off, and `/tw go` still + refuses. +- [ ] Dying still respawns you at the spawn plaza (not a cheat - you lost your cargo + to get there). - [ ] `/tw restart` still returns a destitute player to spawn with a fresh kit, capped by `player.max-restarts`. +- [ ] The old `/tw spawn` label still works as an alias, and does the same thing. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index f4b07bd..36529f4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,7 +3,7 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. -## /tw spawn removed: it had become a free warp home (2026-08-01) +## /tw go: a door into the ocean, not a teleport (2026-08-01) Ben spotted it: "Now that Spawn is a trading post, which is a good thing... the /tw spawn command is a free way to warp back there!" Exactly right, and it @@ -12,15 +12,26 @@ became a real port so new players would trade there, and the free teleport that was harmless when spawn was an empty islet turned into a free ride to a market from anywhere in the galaxy. -Unregistered rather than permission-gated, as asked - a command that exists and -is denied still shows up and still invites "can I have this?". `TWSpawnCommand` -stays in the tree for whenever it is wanted again. - -The catch: `new-player-action` and `default-action` both pointed at `spawn`, so -bare `/tw` would have fallen through to a non-existent sub-command. Both now -default to `help`, which is the most useful thing to hand someone anyway. The -live server config had the old values too - stored config beats changed -defaults, as always. +First cut simply unregistered it. Ben caught the hole immediately: *"so if I'm +in another world and I run /tw, how do I start?"* Deleting the exploit had +deleted the front door - a new player, or anyone standing in another game +mode's world, had no way into the ocean at all. + +So it is a door with two rules instead. It **refuses while you are already at +sea** - that is the exploit, and the whole of it. And coming in from outside +returns you to the water you **left**, not to spawn. That second rule matters +more than it looks: this server runs several game modes, so a spawn-anchored +door would have re-opened the same free ride through the side - `/acid`, then +`/tw`, and you are standing in a market. `SeaPositionTracker` records the spot +on quit and on any teleport out of a TradeWinds world, and only a sailor who +has never set out starts at the spawn port. + +Recording uses `PlayerTeleportEvent`, not `PlayerChangedWorldEvent`: by the +time the latter fires the player has already moved, so their location is the +destination - the one position that is no use. + +Labelled `go` (aliases `spawn`, `sail`), and both default actions point at it, +so bare `/tw` is the natural entry. Death still respawns at the spawn plaza (you paid for that trip with your cargo), and `/tw restart` still returns a destitute player there, capped. diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 733d414..a4aebdd 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -48,15 +48,16 @@ public class Settings implements WorldSettings { @ConfigComment("The default action for new player command call.") @ConfigComment("Sub-command of main player command that will be run on first player command call.") - @ConfigComment("'spawn' is deliberately NOT registered as a player command - it would be a free") - @ConfigComment("warp back to the spawn trading post - so bare /tw shows the command list.") + @ConfigComment("'go' is the door into the ocean: it refuses if you are already at sea, and") + @ConfigComment("returns you to the water you left rather than to spawn, so it is not a free") + @ConfigComment("warp home. Only a sailor who has never set out starts at the spawn port.") @ConfigEntry(path = "tradewinds.command.new-player-action") - private String defaultNewPlayerAction = "help"; + private String defaultNewPlayerAction = "go"; @ConfigComment("The default action for player command.") @ConfigComment("Sub-command of main player command that will be run on each player command call.") @ConfigEntry(path = "tradewinds.command.default-action") - private String defaultPlayerAction = "help"; + private String defaultPlayerAction = "go"; /* GALAXY */ @ConfigComment("The galaxy seed. Every island position, type, security band, biome, name and") diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index ac607ec..ac6f215 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -33,6 +33,7 @@ import world.bentobox.tradewinds.commands.TWChartCommand; import world.bentobox.tradewinds.commands.TWFineCommand; import world.bentobox.tradewinds.commands.TWRestartCommand; +import world.bentobox.tradewinds.commands.TWSpawnCommand; import world.bentobox.tradewinds.commands.TWStarChartCommand; import world.bentobox.tradewinds.commands.TWTradeCommand; import world.bentobox.tradewinds.commands.TWWarpCommand; @@ -64,6 +65,7 @@ import world.bentobox.tradewinds.travel.FuelService; import world.bentobox.tradewinds.travel.IntersticeService; import world.bentobox.tradewinds.travel.HoldService; +import world.bentobox.tradewinds.travel.SeaPositionTracker; import world.bentobox.tradewinds.travel.StarChartService; import world.bentobox.tradewinds.travel.StarterKit; import world.bentobox.tradewinds.travel.WarpService; @@ -117,6 +119,7 @@ public class TradeWinds extends GameModeAddon { private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; private @Nullable FuelWarningTask fuelWarningTask; + private SeaPositionTracker seaPositionTracker; /** * This addon uses the new chunk generation API for the sea bottom @@ -167,11 +170,11 @@ public void setup() { setDescription("tradewinds.commands.help.description"); setOnlyPlayer(true); setPermission("island"); - // NOTE: TWSpawnCommand is deliberately NOT registered. Now that - // spawn is a working trading post, /tw spawn would be a free - // warp back to a market from anywhere - travel is the game. - // Death still respawns there (SpawnRespawnListener), and - // /tw restart still returns a destitute player there. + new TWSpawnCommand(this); + // /tw go is the door into the ocean and nothing more: it + // refuses when you are already at sea, and returns you to the + // water you left rather than to spawn, so neither it nor a + // hop through another game mode is a free ride to a market. new TWWarpCommand(this); new TWChartCommand(this); new TWStarChartCommand(this); @@ -264,6 +267,9 @@ public void onEnable() { registerListener(new BorderPromptListener(this)); // Teleporting while boated brings the boat (and cargo) along registerListener(new BoatPickupListener(this)); + // Where a sailor left the ocean, so coming back is not a teleport + seaPositionTracker = new SeaPositionTracker(this); + registerListener(seaPositionTracker); // Cargo expanders open in the hand (Java cannot open shulkers in an // inventory, and a carried hold has to be openable) registerListener(new ExpanderListener(this)); @@ -549,6 +555,10 @@ public CustomsService getCustomsService() { return customsService; } + public SeaPositionTracker getSeaPositionTracker() { + return seaPositionTracker; + } + public PoliceService getPoliceService() { return policeService; } diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java index 1d6d1cb..52cba91 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java @@ -1,6 +1,7 @@ package world.bentobox.tradewinds.commands; import java.util.List; +import java.util.Optional; import org.bukkit.Location; @@ -11,17 +12,27 @@ import world.bentobox.tradewinds.TradeWinds; /** - * Teleports the player to the ocean spawn. TradeWinds has no spawn island (and - * players own no island until Stage 7), so unlike the core island spawn command - * this needs no Island object - it goes straight to the world spawn location on - * the sea surface. + * The door into the ocean - and only the door. + *

+ * This used to be {@code /tw spawn}, a straight teleport to the world spawn. + * That was harmless while spawn was an empty islet, and became a free warp + * home to a market the moment spawn was made a working trading post. But + * simply deleting it locked new players out, and locked anyone in another world + * out with them. + *

+ * So it is a door with two rules. It refuses to do anything if you are + * already at sea - travel is the game, and there is no commanding your + * way across it. And coming in from outside returns you to the water you + * left, not to spawn, so that hopping to another game mode and back is + * not a free ride home either. Only a sailor who has never set out starts at + * the spawn port. * * @author tastybento */ public class TWSpawnCommand extends DelayedTeleportCommand { public TWSpawnCommand(CompositeCommand parent) { - super(parent, "spawn"); + super(parent, "go", "spawn", "sail"); } @Override @@ -33,14 +44,20 @@ public void setup() { @Override public boolean execute(User user, String label, List args) { - Location spawn = getWorld().getSpawnLocation(); TradeWinds addon = getAddon(); - this.delayCommand(user, () -> Util.teleportAsync(user.getPlayer(), spawn) - .thenAccept(done -> { - user.sendMessage("tradewinds.spawn.teleported"); - // First-timers get boat + bundle; boat carriers get launched - addon.getStarterKit().onSpawnArrival(user.getPlayer()); - })); + if (addon.inWorld(user.getWorld())) { + // Already out there: the way to anywhere is a boat or a warp + user.sendMessage("tradewinds.spawn.already-at-sea"); + return false; + } + Optional last = addon.getSeaPositionTracker().lastKnown(user.getPlayer()); + Location destination = last.orElseGet(() -> getWorld().getSpawnLocation()); + boolean returning = last.isPresent(); + this.delayCommand(user, () -> Util.teleportAsync(user.getPlayer(), destination).thenAccept(done -> { + user.sendMessage(returning ? "tradewinds.spawn.resumed" : "tradewinds.spawn.teleported"); + // First-timers get boat + bundle; boat carriers get launched + addon.getStarterKit().onSpawnArrival(user.getPlayer()); + })); return true; } } diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index 8bcbce8..1063142 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -52,6 +52,13 @@ public class TWPlayerData implements DataObject { @Expose private long lastCharity; + /** + * Where this sailor last was in a TradeWinds world, so leaving the world + * and coming back does not move them. Empty until they first set sail. + */ + @Expose + private String lastSeaPosition; + /** * Reputation score - one global number, positive is good. The standing * bands derive from it (see ReputationScale). @@ -153,6 +160,14 @@ public void setChartedIslands(Set chartedIslands) { this.chartedIslands = chartedIslands; } + public String getLastSeaPosition() { + return lastSeaPosition; + } + + public void setLastSeaPosition(String lastSeaPosition) { + this.lastSeaPosition = lastSeaPosition; + } + public int getReputation() { return reputation; } diff --git a/src/main/java/world/bentobox/tradewinds/travel/SeaPositionTracker.java b/src/main/java/world/bentobox/tradewinds/travel/SeaPositionTracker.java new file mode 100644 index 0000000..059a630 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/SeaPositionTracker.java @@ -0,0 +1,105 @@ +package world.bentobox.tradewinds.travel; + +import java.util.Optional; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; + +/** + * Remembers where a sailor left the ocean. + *

+ * This is what lets {@code /tw} be a door rather than a teleport. Removing the + * old spawn command closed a free ride home, but it also closed the only way + * in from another world - and on a server running several game modes, + * simply putting the door back at spawn would have re-opened the exploit + * through the side: hop to another gamemode, hop back, arrive at a market. + *

+ * So coming back returns you to the water you left. Only a sailor who has + * never set out goes to spawn. + * + * @author tastybento + */ +public class SeaPositionTracker implements Listener { + + private final TradeWinds addon; + + public SeaPositionTracker(TradeWinds addon) { + this.addon = addon; + } + + /** + * Record a player's position if they are in a TradeWinds world. + * + * @param player the player + */ + public void record(Player player) { + if (addon.inWorld(player.getWorld())) { + store(player, player.getLocation()); + } + } + + /** + * Where a player left the ocean, if they ever set out. + * + * @param player the player + * @return their last position, or empty for a sailor who has never sailed + */ + public Optional lastKnown(Player player) { + String stored = addon.getPlayerDataManager().get(player.getUniqueId()).getLastSeaPosition(); + if (stored == null || stored.isBlank()) { + return Optional.empty(); + } + String[] parts = stored.split(";"); + if (parts.length != 4) { + return Optional.empty(); + } + World world = addon.getPlugin().getServer().getWorld(parts[0]); + if (world == null || !addon.inWorld(world)) { + return Optional.empty(); + } + try { + return Optional.of(new Location(world, Integer.parseInt(parts[1]) + 0.5, + Integer.parseInt(parts[2]), Integer.parseInt(parts[3]) + 0.5)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + record(event.getPlayer()); + } + + /** + * Record the spot they left from when a teleport takes them out of a + * TradeWinds world. + *

+ * The teleport event is used rather than {@code PlayerChangedWorldEvent} + * because by the time that fires the player has already moved, and their + * location is the destination - the one position that is no use here. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTeleport(PlayerTeleportEvent event) { + if (event.getTo() == null || !addon.inWorld(event.getFrom().getWorld()) + || addon.inWorld(event.getTo().getWorld())) { + return; + } + store(event.getPlayer(), event.getFrom()); + } + + private void store(Player player, Location at) { + TWPlayerData data = addon.getPlayerDataManager().get(player.getUniqueId()); + data.setLastSeaPosition( + at.getWorld().getName() + ";" + at.getBlockX() + ";" + at.getBlockY() + ";" + at.getBlockZ()); + addon.getPlayerDataManager().save(player.getUniqueId()); + } +} diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index d3b4fe4..0ddac94 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -14,10 +14,10 @@ permissions: default: true tradewinds.island.spawn: description: > - Unused: the spawn command is not registered. Spawn is a working trading - post, so /tw spawn would be a free warp back to a market from anywhere. - Kept so servers that re-enable the command have the node ready. - default: false + Allow /tw go - the door into the ocean. It refuses while the player is + already at sea and returns them to the water they left, so it is not a + free warp home. + default: true tradewinds.island.warp: description: > Shortcut: open the warp dialog from anywhere in island waters. Off by diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 47e8e21..14ba1ac 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,11 +6,12 @@ tradewinds: # The admin command. admin: twadmin # The default action for new player command call. - # 'spawn' is deliberately NOT registered as a player command - it would be a free - # warp back to the spawn trading post - so bare /tw shows the command list. - new-player-action: help + # 'go' is the door into the ocean: it refuses if you are already at sea, and + # returns you to the water you left rather than to spawn, so it is not a free + # warp home. Only a sailor who has never set out starts at the spawn port. + new-player-action: go # The default action for player command. - default-action: help + default-action: go galaxy: # The galaxy seed. Every island position, type, security band, biome, name and # route cost derives deterministically from this one number - share it and another diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 97e4adf..d82d945 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -16,7 +16,7 @@ tradewinds: help: description: "TradeWinds trading command" spawn: - description: "teleport to the ocean spawn" + description: "set sail - enter the ocean where you left it" warp: description: "open the warp dialog (in a boat, at an island)" chart: @@ -65,6 +65,8 @@ tradewinds: done: "Re-applied band flags to [number] trading islands." spawn: teleported: "You set sail on the open ocean." + resumed: "You take up your voyage where you left it." + already-at-sea: "You are already at sea. There is no commanding your way across it - row, or warp from an island's border." starter-kit: given: "Your trading career begins: a boat, a trading pouch, and a little coal. Buy low, sell high." standing: diff --git a/src/test/java/world/bentobox/tradewinds/SettingsTest.java b/src/test/java/world/bentobox/tradewinds/SettingsTest.java index 05682f4..4085de1 100644 --- a/src/test/java/world/bentobox/tradewinds/SettingsTest.java +++ b/src/test/java/world/bentobox/tradewinds/SettingsTest.java @@ -29,10 +29,9 @@ public void setUp() throws Exception { void testCommands() { assertEquals("tw tradewinds", settings.getPlayerCommandAliases()); assertEquals("twadmin", settings.getAdminCommandAliases()); - // 'spawn' is not a registered player command - it would be a free warp - // back to the spawn trading post - so bare /tw lists the commands - assertEquals("help", settings.getDefaultNewPlayerAction()); - assertEquals("help", settings.getDefaultPlayerAction()); + // 'go' is the door into the ocean, and refuses once you are at sea + assertEquals("go", settings.getDefaultNewPlayerAction()); + assertEquals("go", settings.getDefaultPlayerAction()); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java index 578f511..1527535 100644 --- a/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java +++ b/src/test/java/world/bentobox/tradewinds/TradeWindsTest.java @@ -140,10 +140,11 @@ void testOnLoad() { assertTrue(addon.getAdminCommand().isPresent()); assertNotNull(addon.getBiomeProvider()); // Players CANNOT create free islands - player islands are purchased - // (Stage 7) - and cannot command their way home either: spawn is a - // working trading post, so /tw spawn would be a free warp to a market - // from anywhere. Travel is the game. - assertTrue(addon.getPlayerCommand().get().getSubCommand("spawn").isEmpty()); + // (Stage 7). /tw go is the door into the ocean, and the old 'spawn' + // label still reaches it, but it refuses once you are at sea: spawn is + // a working trading post, so a teleport there would be a free warp to a + // market from anywhere. Travel is the game. + assertTrue(addon.getPlayerCommand().get().getSubCommand("go").isPresent()); assertTrue(addon.getPlayerCommand().get().getSubCommand("create").isEmpty()); assertTrue(addon.getPlayerCommand().get().getSubCommand("reset").isEmpty()); // The commands that remain are the ones that require being somewhere From 5f59c6b040a2f7b4069aa44d6ae97bd84ef148a9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 08:56:18 -0700 Subject: [PATCH 061/112] Add a DOCK marker to the hologram chart The chart answered "where is everywhere else" and said nothing about the one bearing a sailor in these waters actually needs. Inside an island's waters it now raises a DOCK hologram pointing at that island's pier, with distance. It points at the pier rather than the island centre, hangs below the island names so it never collides with their stack, and takes a warmer background so it reads as a different kind of thing. It appears over the same reach the navigation bar uses, so the marker shows up exactly when the bar starts counting down to the pier - and it now shows even when the chart is empty, which previously drew nothing at all. Label is a locale key like everything else. 207 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 15 ++++ .../tradewinds/travel/ChartHolograms.java | 73 +++++++++++++++++-- src/main/resources/locales/en-US.yml | 1 + .../travel/ChartNavigationTest.java | 31 ++++++++ 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/TESTING.md b/TESTING.md index d6a369b..0b33608 100644 --- a/TESTING.md +++ b/TESTING.md @@ -858,3 +858,18 @@ two rules. - [ ] `/tw restart` still returns a destitute player to spawn with a fresh kit, capped by `player.max-restarts`. - [ ] The old `/tw spawn` label still works as an alias, and does the same thing. + +## Dock marker on the hologram chart + +- [ ] Inside an island's waters, `/tw chart` (or boarding a boat) now raises a + **DOCK** hologram alongside the island names, pointing at that island's pier + with its distance. Row toward it and the number falls. +- [ ] It points at the **pier**, not the island centre - check on an island whose + dock faces away from you. +- [ ] It hangs **below** the island name markers and has a warmer background, so it + never collides with their stack. +- [ ] Out in open ocean, away from any island, there is no dock marker - just the + island names. +- [ ] With an empty chart but inside an island's waters, the dock marker still + appears on its own. (Previously an empty chart showed nothing at all.) +- [ ] The label is translatable: `tradewinds.hologram.dock`. diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index cec3f79..96ae5e4 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -18,6 +18,7 @@ import net.kyori.adventure.text.Component; import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.DockPlan; import world.bentobox.tradewinds.galaxy.IslandSpec; /** @@ -35,6 +36,8 @@ public class ChartHolograms { /** Bearing sector width for stacking, degrees. */ private static final double SECTOR_DEGREES = 20.0; private static final double BASE_HEIGHT = 1.2; + /** The dock marker hangs below the island names, clear of their stack. */ + private static final double DOCK_HEIGHT = 0.2; private static final double STACK_STEP = 0.8; private static final int ZOOM_TICKS = 12; @@ -47,8 +50,37 @@ public ChartHolograms(TradeWinds addon) { /** * One hologram's placement: offset from the player and its label parts. + * + * @param dock true for the marker pointing at the island's own pier rather + * than at a distant island + */ + record Marker(double dx, double dy, double dz, IslandSpec island, int distance, boolean dock) { + } + + /** + * The marker pointing at the pier of the island you are currently in the + * waters of, if you are in any. + *

+ * The chart answers "where is everywhere else" perfectly well and used to + * say nothing at all about the one thing a sailor actually needs next: + * which way is the dock. Pure, so the bearing is testable. + * + * @param island the island whose waters the player is in + * @param plan its dock plan + * @param px player block x + * @param pz player block z + * @param radius ring radius in blocks + * @return the dock marker */ - record Marker(double dx, double dy, double dz, IslandSpec island, int distance) { + static Marker dockMarker(IslandSpec island, DockPlan plan, int px, int pz, double radius) { + int pierX = island.centerX() + (int) Math.round(Math.cos(plan.bearing()) * plan.dockEnd()); + int pierZ = island.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * plan.dockEnd()); + double dx = (double) pierX - px; + double dz = (double) pierZ - pz; + double dist = Math.max(1.0, Math.hypot(dx, dz)); + // Sits below the island names: it is the nearest thing, and keeping it + // out of their stack means it never collides with one + return new Marker(dx / dist * radius, DOCK_HEIGHT, dz / dist * radius, island, (int) dist, true); } /** @@ -78,7 +110,7 @@ static List markers(List islands, int px, int pz, double rad int sector = (int) Math.floor((bearing + 180.0) / SECTOR_DEGREES); int rank = sectorRank.merge(sector, 1, Integer::sum) - 1; result.add(new Marker(dx / dist * radius, BASE_HEIGHT + rank * STACK_STEP, dz / dist * radius, spec, - (int) dist)); + (int) dist, false)); } return result; } @@ -96,20 +128,27 @@ public void show(Player player) { world.bentobox.bentobox.api.localization.TextVariables.NUMBER, String.valueOf(scanned.size())); } - List charted = chartedIslands(player); - if (charted.isEmpty()) { + Location eye = player.getLocation(); + double radius = addon.getSettings().getChartHologramDistance(); + List markers = new ArrayList<>(); + // Which way is the dock - the one bearing a sailor in these waters + // actually needs, and the one the chart never used to give them + dockIsland(eye.getBlockX(), eye.getBlockZ()).ifPresent(island -> markers.add(dockMarker(island, + addon.getGalaxyEngine(addon.getOverWorld().getSeed()).dockPlan(island), eye.getBlockX(), + eye.getBlockZ(), radius))); + markers.addAll(markers(chartedIslands(player), eye.getBlockX(), eye.getBlockZ(), radius, + addon.getSettings().getChartHologramMax())); + if (markers.isEmpty()) { return; } - Location eye = player.getLocation(); - List markers = markers(charted, eye.getBlockX(), eye.getBlockZ(), - addon.getSettings().getChartHologramDistance(), addon.getSettings().getChartHologramMax()); List spawned = new ArrayList<>(); for (Marker marker : markers) { TextDisplay display = eye.getWorld().spawn(eye.clone().add(0, 1.0, 0), TextDisplay.class); display.text(label(player, marker)); display.setBillboard(Billboard.CENTER); display.setSeeThrough(true); - display.setBackgroundColor(Color.fromARGB(120, 0, 20, 40)); + display.setBackgroundColor(marker.dock() ? Color.fromARGB(140, 60, 40, 0) + : Color.fromARGB(120, 0, 20, 40)); display.setPersistent(false); display.setTeleportDuration(ZOOM_TICKS); // Only the caller sees their own compass @@ -133,8 +172,26 @@ public void show(Player player) { addon.getSettings().getChartHologramSeconds() * 20L); } + /** + * The island whose waters the player is in, if any - the same reach the + * navigation bar uses, so the dock marker appears exactly when the bar + * starts counting down the distance to the pier. + */ + private java.util.Optional dockIsland(int x, int z) { + if (addon.getOverWorld() == null) { + return java.util.Optional.empty(); + } + int range = addon.getSettings().getIslandDistance(); + return addon.getGalaxyEngine(addon.getOverWorld().getSeed()).islandsNear(x, z, range).stream() + .filter(spec -> spec.distanceSquared(x, z) <= (long) range * range).findFirst(); + } + private Component label(Player player, Marker marker) { IslandSpec spec = marker.island(); + if (marker.dock()) { + return User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.dock", + "[name]", spec.name(), "[distance]", String.valueOf(marker.distance())); + } return User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.island", "[name]", spec.name(), "[type]", spec.type().name(), diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index d82d945..2e24cfd 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -116,6 +116,7 @@ tradewinds: navigation: "[name] | [band] | [standing] | Dock [distance]m" hologram: island: "[name][type], [band][distance]m" + dock: "DOCK[name][distance]m" ui: market: title: "[name] Market" diff --git a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java index fde2b03..5febbcd 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; +import world.bentobox.tradewinds.galaxy.DockPlan; import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandSpec; @@ -22,6 +23,36 @@ class ChartNavigationTest { private final GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(77L, 2500, 160, 45, 1.0, 0, 5000, 70)); + @Test + void testDockMarkerPointsAtThePier() { + // The chart answered "where is everywhere else" and said nothing about + // the one bearing a sailor in these waters actually needs + IslandSpec island = engine.islandInCell(1, 1).orElseThrow(); + DockPlan plan = engine.dockPlan(island); + int pierX = island.centerX() + (int) Math.round(Math.cos(plan.bearing()) * plan.dockEnd()); + int pierZ = island.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * plan.dockEnd()); + // Stand just off the island, not at the origin + int px = island.centerX() + 300; + int pz = island.centerZ() + 300; + + ChartHolograms.Marker marker = ChartHolograms.dockMarker(island, plan, px, pz, 10.0); + assertTrue(marker.dock(), "The dock marker must be flagged so it is labelled and coloured as one"); + // On the ring, pointing at the pier - NOT at the island centre + assertEquals(10.0, Math.hypot(marker.dx(), marker.dz()), 0.01); + assertEquals(Math.atan2((double) pierZ - pz, (double) pierX - px), + Math.atan2(marker.dz(), marker.dx()), 0.001); + assertEquals((int) Math.hypot((double) pierX - px, (double) pierZ - pz), marker.distance()); + // Below the island names so it never collides with their stack + assertTrue(marker.dy() < 1.2, "Dock marker should hang below the island markers"); + } + + @Test + void testIslandMarkersAreNotDockMarkers() { + IslandSpec spec = engine.islandInCell(1, 1).orElseThrow(); + ChartHolograms.markers(List.of(spec), 0, 0, 10.0, 12) + .forEach(m -> assertTrue(!m.dock(), "Island markers must not be flagged as docks")); + } + @Test void testMarkersPointTheRightWay() { // Not cell 0,0 - that is the spawn island, directly under the player From fee3fdaef3364515697c6f65bb70501a3eb6abcd Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 09:09:58 -0700 Subject: [PATCH 062/112] Star chart: fuel range ring, reachability in the list, white names Three playtest asks, all about the chart saying what a sailor can do rather than only where things are. A warp costs fuel per block of route, so the reachable set genuinely is a circle - a thing a chart can draw and a number cannot. The ring is dashed so it reads as an annotation rather than a wall, drawn before the islands so dots and names stay on top, and skipped entirely when its radius runs off the canvas: a ring clamped to the edge would lie about your range. /tw chart list now carries each entry's fuel cost, green when reachable and greyed with "(not enough fuel)" when not, under a Fuel aboard line. At a port that is the exact route price including lane overrides; adrift it is the same distance-based estimate from the player, close enough to plan by. Names are white. MapCanvas takes its text colour from a palette-index prefix, and the default was a mid grey that all but vanished against the ocean. 209 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 19 ++++++ docs/PROGRESS.md | 25 +++++++ .../tradewinds/commands/TWChartCommand.java | 45 +++++++++++-- .../tradewinds/travel/StarChartRenderer.java | 67 ++++++++++++++++++- src/main/resources/locales/en-US.yml | 4 +- .../travel/ChartNavigationTest.java | 22 ++++++ 6 files changed, 175 insertions(+), 7 deletions(-) diff --git a/TESTING.md b/TESTING.md index 0b33608..ab9daf6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -873,3 +873,22 @@ two rules. - [ ] With an empty chart but inside an island's waters, the dock marker still appears on its own. (Previously an empty chart showed nothing at all.) - [ ] The label is translatable: `tradewinds.hologram.dock`. + +## Star chart: fuel range, reachability, readable names + +- [ ] Hold the Star Chart: island names are **white** and clearly readable against + the blue ocean (they were a mid grey that all but vanished). +- [ ] A **dashed ring** is drawn around you at the limit of your fuel range. Burn or + buy fuel and it shrinks/grows on the next redraw (about a second). +- [ ] With no fuel there is no ring; with a huge amount the ring is off the chart and + is simply not drawn, rather than being clamped to the edge and lying about it. +- [ ] Islands inside the ring are the ones you can afford to warp to - cross-check + against `/tw chart list`. +- [ ] `/tw chart list` shows **Fuel aboard: N units** and marks every entry with its + fuel cost: green and plain when reachable, greyed with "(not enough fuel)" when + it is not. +- [ ] Standing at a port, the quoted cost matches the warp dialog exactly (it uses + the same route price, including any lane overrides in + `travel.warp.edge-overrides`). +- [ ] Adrift between islands the cost is an estimate from your position - close + enough to plan by. It should still fall as you approach a destination. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 36529f4..be69f37 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,31 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Star chart: fuel range ring, reachability, readable names (2026-08-01) + +Three playtest asks, all about the chart telling a sailor what they can +actually do rather than only where things are. + +**The fuel range ring.** A warp costs fuel per block of route, so the reachable +set genuinely is a circle - which is a thing a chart can draw and a number +cannot. Dashed, so it reads as an annotation rather than a wall, and drawn +before the islands so dots and names stay on top of it. It is skipped entirely +when the radius runs off the canvas: a ring clamped to the edge would be a lie +about your range, and worse than no ring at all. + +**Reachability in `/tw chart list`.** A list of places you cannot afford to go +is a list of disappointments. Each entry now carries its fuel cost, green when +reachable and greyed with "(not enough fuel)" when not, under a "Fuel aboard" +line. Standing at a port the figure is the exact route price, lane overrides +included; adrift it is the same distance-based estimate measured from the +player, which is close enough to plan by. + +**White names.** `MapCanvas` takes its text colour from a +section-sign/palette-index/semicolon prefix, and with no prefix the default was +a mid grey that all but vanished against the ocean blue. + +209 tests green. + ## /tw go: a door into the ocean, not a teleport (2026-08-01) Ben spotted it: "Now that Spawn is a trading post, which is a good thing... diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java index 32d8743..18fd276 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -60,12 +60,47 @@ && getWorld().equals(user.getWorld())) { user.sendMessage("tradewinds.chart.empty"); return true; } + // What the fuel aboard can actually reach. A list of places you cannot + // afford to go is a list of disappointments, so say which is which. + double fuelAboard = addon.getFuelService().holdFuel(user.getPlayer()); + IslandSpec origin = portAt(engine, x, z).orElse(null); user.sendMessage("tradewinds.chart.header", TextVariables.NUMBER, String.valueOf(charted.size())); - charted.forEach(spec -> user.sendMessage("tradewinds.chart.entry", - TextVariables.NAME, spec.name(), - "[type]", spec.type().name(), - "[band]", spec.band().getDisplayName(), - "[distance]", String.valueOf((int) Math.sqrt(spec.distanceSquared(x, z))))); + user.sendMessage("tradewinds.chart.fuel-aboard", "[amount]", String.valueOf((int) fuelAboard)); + charted.forEach(spec -> { + int cost = routeCost(addon, origin, spec, x, z); + boolean reachable = cost <= fuelAboard; + user.sendMessage(reachable ? "tradewinds.chart.entry-reachable" : "tradewinds.chart.entry-far", + TextVariables.NAME, spec.name(), + "[type]", spec.type().name(), + "[band]", user.getTranslation(spec.band().getLocaleKey()), + "[distance]", String.valueOf((int) Math.sqrt(spec.distanceSquared(x, z))), + "[fuel]", String.valueOf(cost)); + }); return true; } + + /** + * The trading island the player is standing in the waters of, if any - + * warps launch from an island, so that is the origin a cost is measured + * from when there is one. + */ + private java.util.Optional portAt(GalaxyEngine engine, int x, int z) { + int range = ((TradeWinds) getAddon()).getSettings().getIslandProtectionRange(); + return engine.islandsNear(x, z, range).stream() + .filter(s -> s.distanceSquared(x, z) <= (long) range * range).findFirst(); + } + + /** + * The fuel a warp to this island would cost. From a port that is the exact + * route price, honouring any lane overrides; adrift it is the same + * distance-based estimate the route graph would charge, measured from the + * player - close enough to plan by, which is what a chart is for. + */ + private int routeCost(TradeWinds addon, IslandSpec origin, IslandSpec target, int x, int z) { + if (origin != null) { + return origin.equals(target) ? 0 : addon.getRouteGraph().cost(origin, target); + } + double distance = Math.sqrt(target.distanceSquared(x, z)); + return Math.max(1, (int) Math.ceil(distance * addon.getSettings().getFuelPerBlock())); + } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java b/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java index 6ddbda4..63e98b8 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java @@ -11,6 +11,7 @@ import org.bukkit.map.MapCursorCollection; import org.bukkit.map.MapRenderer; import org.bukkit.map.MapView; +import org.bukkit.map.MapPalette; import org.bukkit.map.MinecraftFont; import world.bentobox.tradewinds.TradeWinds; @@ -31,6 +32,10 @@ public class StarChartRenderer extends MapRenderer { private static final Color OCEAN = new Color(12, 44, 84); private static final Color EDGE = new Color(120, 120, 120); + /** The fuel-range ring: pale enough to read over, solid enough to see. */ + private static final Color RANGE_RING = new Color(120, 220, 235); + /** Names are drawn white: anything darker vanishes against the ocean. */ + private static final Color NAME = new Color(255, 255, 255); private final TradeWinds addon; private final Map lastDraw = new HashMap<>(); @@ -41,6 +46,22 @@ public StarChartRenderer(TradeWinds addon) { this.addon = addon; } + /** + * How far this much fuel will carry a sailor, in blocks. The warp costs + * fuel per block of route, so the reachable set really is a circle - which + * is exactly the thing a chart can draw and a number cannot. + * + * @param fuelAboard fuel units in the hold + * @param fuelPerBlock fuel cost per block of route + * @return range in blocks, 0 if fuel buys nothing + */ + static long fuelRangeBlocks(double fuelAboard, double fuelPerBlock) { + if (fuelPerBlock <= 0 || fuelAboard <= 0) { + return 0; + } + return (long) Math.floor(fuelAboard / fuelPerBlock); + } + /** * World offset to canvas pixel (center 64,64), or edge-clamped when out of * range. Pure and testable. @@ -86,6 +107,9 @@ public void render(MapView view, MapCanvas canvas, Player player) { canvas.setPixelColor(x, z, OCEAN); } } + // How far the fuel aboard will carry them - drawn before the islands so + // island dots and names stay on top of it + drawFuelRange(canvas, addon.getFuelService().holdFuel(player), bpp); // Charted islands GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); int islandPixelRadius = Math.max(1, engine.getConfig().terrainRadius() / bpp); @@ -111,6 +135,32 @@ public void render(MapView view, MapCanvas canvas, Player player) { canvas.setCursors(cursors); } + /** + * A dashed ring at the edge of the fuel's reach. Dashed so it reads as an + * annotation rather than a wall, and skipped entirely when the range runs + * off the chart - a ring you cannot see is just a lie about your range. + */ + private void drawFuelRange(MapCanvas canvas, double fuelAboard, int bpp) { + long range = fuelRangeBlocks(fuelAboard, addon.getSettings().getFuelPerBlock()); + int radius = (int) (range / bpp); + if (radius < 4 || radius > 62) { + return; + } + // Step by roughly one pixel of arc, and draw two thirds of each turn + int steps = Math.max(48, radius * 6); + for (int i = 0; i < steps; i++) { + if (i % 3 == 2) { + continue; // the gaps in the dashes + } + double angle = 2 * Math.PI * i / steps; + int x = 64 + (int) Math.round(Math.cos(angle) * radius); + int z = 64 + (int) Math.round(Math.sin(angle) * radius); + if (x >= 0 && x < 128 && z >= 0 && z < 128) { + canvas.setPixelColor(x, z, RANGE_RING); + } + } + } + private void fillDot(MapCanvas canvas, int cx, int cz, int radius, Color color) { for (int x = -radius; x <= radius; x++) { for (int z = -radius; z <= radius; z++) { @@ -125,11 +175,26 @@ private void fillDot(MapCanvas canvas, int cx, int cz, int radius, Color color) } } + /** + * Island names, in white. MapCanvas takes its text colour from a + * "section-sign, palette index, semicolon" prefix; the default was a mid + * grey that all but disappeared against the ocean. + */ private void drawName(MapCanvas canvas, int x, int z, String name) { int width = MinecraftFont.Font.getWidth(name); int textX = Math.clamp(x - width / 2, 1, 127 - width); int textZ = Math.clamp(z + 3, 1, 119); - canvas.drawText(textX, textZ, MinecraftFont.Font, name); + canvas.drawText(textX, textZ, MinecraftFont.Font, colored(name)); + } + + /** + * Prefix a string with the map-palette colour code for white. + * + * @param text the text + * @return the text with a colour prefix + */ + static String colored(String text) { + return "\u00A7" + MapPalette.matchColor(NAME) + ";" + text; } static Color bandColor(SecurityBand band) { diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 2e24cfd..3273796 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -191,7 +191,9 @@ tradewinds: starchart-given: "The Star Chart is yours - hold it to see your world." empty: "Your chart is empty - row into an island's waters to chart it." header: "Your chart ([number] islands):" - entry: "[name] - [type], [band], [distance] blocks" + fuel-aboard: "Fuel aboard: [amount] units. Reachable destinations are marked." + entry-reachable: "[fuel] fuel [name] - [type], [band], [distance] blocks" + entry-far: "[fuel] fuel [name] - [type], [band], [distance] blocks (not enough fuel)" restart: confirm: "Restart your career? Balance and kit reset; your chart is kept." none-left: "No restarts left (maximum [number])." diff --git a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java index 5febbcd..4c3cbe6 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java @@ -23,6 +23,28 @@ class ChartNavigationTest { private final GalaxyEngine engine = new GalaxyEngine(new GalaxyConfig(77L, 2500, 160, 45, 1.0, 0, 5000, 70)); + @Test + void testFuelRangeIsWhatTheFuelActuallyBuys() { + // The chart draws the reachable set as a ring, so the radius has to be + // the real thing: fuel divided by the per-block cost + assertEquals(1000L, StarChartRenderer.fuelRangeBlocks(10, 0.01)); + assertEquals(0L, StarChartRenderer.fuelRangeBlocks(0, 0.01)); + // Floor, never round up - a ring you cannot quite reach is worse than none + assertEquals(1L, StarChartRenderer.fuelRangeBlocks(1.99, 1.0)); + // Free warps are not a circle at all, so draw nothing + assertEquals(0L, StarChartRenderer.fuelRangeBlocks(50, 0)); + assertEquals(0L, StarChartRenderer.fuelRangeBlocks(-5, 0.01)); + } + + @Test + void testMapTextIsColouredWhite() { + // MapCanvas takes its text colour from a palette-index prefix; without + // one the names are a mid grey that vanishes against the ocean + String coloured = StarChartRenderer.colored("Spawn"); + assertTrue(coloured.startsWith("\u00A7"), "Missing the map colour prefix"); + assertTrue(coloured.endsWith(";Spawn"), "The name must follow the prefix: " + coloured); + } + @Test void testDockMarkerPointsAtThePier() { // The chart answered "where is everywhere else" and said nothing about From 5a9eaaaa92b339e9b306d2ebf8a753c11f8a0bc5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 09:24:37 -0700 Subject: [PATCH 063/112] Close dialogs under attack, and refuse warps with enemies close A dialog is a modal screen: reading the warp list while a patrol closes in means not seeing the boat, the water, or the thing shooting. Any damage now closes any open dialog - deliberately any damage, since drowning while reading a shop menu deserves the same treatment. The warp gate is the more interesting half, because it turns a UI nicety into a rule: a warp is no longer a panic button out of a fight, which makes the customs chase mean what the spec says it means. Caught with contraband the choices are run, fight or jettison; warping away would have been a silent fourth option that beat all three. Checked when the dialog opens, again when a destination is clicked (a patrol can arrive while the menu is up), and during the stand-still countdown where it refunds the fuel. The interstice re-engage is exempt and that exemption is load-bearing: it is the way out of a place designed to be dangerous, and gating it could strand a player permanently - the exact failure the free re-engage exists to prevent. It goes through deliver rather than warp, now documented in both classes so nobody tidies the two paths together. 209 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 21 +++++ docs/PROGRESS.md | 28 ++++++ .../world/bentobox/tradewinds/Settings.java | 10 +++ .../world/bentobox/tradewinds/TradeWinds.java | 9 ++ .../tradewinds/travel/DialogGuard.java | 88 +++++++++++++++++++ .../tradewinds/travel/WarpService.java | 17 ++++ src/main/resources/config.yml | 6 ++ src/main/resources/locales/en-US.yml | 1 + 8 files changed, 180 insertions(+) create mode 100644 src/main/java/world/bentobox/tradewinds/travel/DialogGuard.java diff --git a/TESTING.md b/TESTING.md index ab9daf6..7c97417 100644 --- a/TESTING.md +++ b/TESTING.md @@ -892,3 +892,24 @@ two rules. `travel.warp.edge-overrides`). - [ ] Adrift between islands the cost is an estimate from your position - close enough to plan by. It should still fall as you approach a destination. + +## Dialogs close under attack; no warping out of a fight + +- [ ] Open any dialog (warp, market, shipwright) and take damage: the dialog + **closes**. A modal screen hides the boat, the water and whatever is shooting. +- [ ] Any damage does it, not just mobs - drown while reading a shop menu. +- [ ] With a hostile mob within 12 blocks, the warp dialog **refuses to open** and an + action bar says "You cannot warp while enemies are close." Same idea as a bed + refusing to let you sleep. +- [ ] Kill or outrun the mob and the dialog opens normally. +- [ ] Let a mob arrive **while the dialog is already open**, then click a + destination: the warp is refused, and no fuel is taken. (Checked again at the + moment of engaging, because that is when the fuel is spent.) +- [ ] With `travel.warp.stand-still-seconds` above 0, a mob arriving during the + countdown aborts the warp and **refunds** the fuel. +- [ ] **A customs chase cannot be escaped by warping** - the patrol counts as + enemies. Run, fight or jettison, as designed. +- [ ] **The interstice re-engage still works with ghasts nearby.** This is the one + exemption and it matters: it is the way out of somewhere dangerous, and gating + it would strand players. +- [ ] `travel.warp.enemy-radius: 0` disables the gate (dialogs still close on damage). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index be69f37..d5d998a 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,34 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Dialogs close under attack, and no warping out of a fight (2026-08-01) + +Ben: a dialog should exit if you are being attacked, with a bed-style action +bar - "cannot warp while enemies are close". + +A dialog is a modal screen: reading the warp list while a patrol closes in +means not seeing the boat, the water, or the thing shooting. Any damage now +closes any open dialog - deliberately *any* damage, since drowning while +reading a shop menu deserves the same treatment. + +The warp gate is the more interesting half, because it turns a UI nicety into a +rule: a warp is no longer a panic button out of a fight, which makes the +customs chase mean what section 6 says it means. Caught with contraband, the +choices are run, fight or jettison - warping away would have been a silent +fourth option that beat all three. Checked in three places, because the fuel is +spent at engagement: when the dialog opens, again when a destination is +clicked (a patrol can arrive while the menu is up), and during the stand-still +countdown, where it refunds. + +**The interstice re-engage is exempt, and that exemption is load-bearing.** It +is the way *out* of a place designed to be dangerous, and gating it behind "no +enemies nearby" could strand a player permanently - the exact failure the free +re-engage exists to prevent. It goes through `deliver` rather than `warp`, so +it never meets the check; that is now written down in both classes so nobody +"tidies" the two paths together. + +209 tests green. + ## Star chart: fuel range ring, reachability, readable names (2026-08-01) Three playtest asks, all about the chart telling a sailor what they can diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index a4aebdd..976421a 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -407,6 +407,14 @@ public double bountyFor(Crime crime) { @ConfigEntry(path = "travel.warp.stand-still-seconds") private int warpStandStillSeconds = 0; + @ConfigComment("Hostile mobs within this many blocks stop a warp engaging, the way monsters") + @ConfigComment("stop you sleeping in a bed. A warp is not a panic button out of a fight or a") + @ConfigComment("customs chase - run, fight, or jettison. 0 disables the check.") + @ConfigComment("The interstice re-engage is always exempt: it is the way OUT of somewhere") + @ConfigComment("dangerous, and gating it could strand a player for good.") + @ConfigEntry(path = "travel.warp.enemy-radius") + private double warpEnemyRadius = 12.0; + @ConfigComment("Seconds of nausea after a warp. Warping hurts - it gates the under-equipped.") @ConfigEntry(path = "travel.warp.nausea-seconds") private int warpNauseaSeconds = 8; @@ -2778,6 +2786,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWarpPromptCooldownSeconds(int warpPromptCooldownSeconds) { this.warpPromptCooldownSeconds = warpPromptCooldownSeconds; } public int getMaxWarpDestinations() { return maxWarpDestinations; } public void setMaxWarpDestinations(int maxWarpDestinations) { this.maxWarpDestinations = maxWarpDestinations; } + public double getWarpEnemyRadius() { return warpEnemyRadius; } + public void setWarpEnemyRadius(double v) { this.warpEnemyRadius = v; } public int getWarpStandStillSeconds() { return warpStandStillSeconds; } public void setWarpStandStillSeconds(int warpStandStillSeconds) { this.warpStandStillSeconds = warpStandStillSeconds; } public int getWarpNauseaSeconds() { return warpNauseaSeconds; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index ac6f215..39b2392 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -60,6 +60,7 @@ import world.bentobox.tradewinds.travel.BoatPickupListener; import world.bentobox.tradewinds.travel.BorderPromptListener; import world.bentobox.tradewinds.travel.ChartHolograms; +import world.bentobox.tradewinds.travel.DialogGuard; import world.bentobox.tradewinds.travel.ChartingListener; import world.bentobox.tradewinds.travel.ExpanderListener; import world.bentobox.tradewinds.travel.FuelService; @@ -120,6 +121,7 @@ public class TradeWinds extends GameModeAddon { private @Nullable NavigationBarTask navigationBarTask; private @Nullable FuelWarningTask fuelWarningTask; private SeaPositionTracker seaPositionTracker; + private DialogGuard dialogGuard; /** * This addon uses the new chunk generation API for the sea bottom @@ -267,6 +269,9 @@ public void onEnable() { registerListener(new BorderPromptListener(this)); // Teleporting while boated brings the boat (and cargo) along registerListener(new BoatPickupListener(this)); + // Being attacked closes any open menu, and gates the warp + dialogGuard = new DialogGuard(this); + registerListener(dialogGuard); // Where a sailor left the ocean, so coming back is not a teleport seaPositionTracker = new SeaPositionTracker(this); registerListener(seaPositionTracker); @@ -555,6 +560,10 @@ public CustomsService getCustomsService() { return customsService; } + public DialogGuard getDialogGuard() { + return dialogGuard; + } + public SeaPositionTracker getSeaPositionTracker() { return seaPositionTracker; } diff --git a/src/main/java/world/bentobox/tradewinds/travel/DialogGuard.java b/src/main/java/world/bentobox/tradewinds/travel/DialogGuard.java new file mode 100644 index 0000000..72c2ab2 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/DialogGuard.java @@ -0,0 +1,88 @@ +package world.bentobox.tradewinds.travel; + +import java.util.List; + +import org.bukkit.entity.Enemy; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Shuts a dialog when the world starts hitting you. + *

+ * A menu is a modal screen: a sailor reading the warp list while a patrol + * closes in cannot see the boat, the water, or the thing shooting at them. + * Being attacked closes it, on the same principle as a bed refusing to let you + * sleep with monsters about - and warping is gated the same way, so a warp + * cannot be a panic button out of a fight or a customs chase. + *

+ * The interstice re-engage is deliberately exempt: it is the way out + * of a place that is supposed to be dangerous, and gating it behind "no enemies + * nearby" could strand a player for good. It goes through + * {@code WarpService#deliver} rather than {@code warp}, so it never meets this + * check. + * + * @author tastybento + */ +public class DialogGuard implements Listener { + + private final TradeWinds addon; + + public DialogGuard(TradeWinds addon) { + this.addon = addon; + } + + /** + * Whether hostile mobs are close enough to stop a warp. + * + * @param player the player + * @return true if something is hunting them + */ + public boolean enemiesNear(Player player) { + double radius = addon.getSettings().getWarpEnemyRadius(); + if (radius <= 0) { + return false; + } + return !nearbyEnemies(player, radius).isEmpty(); + } + + /** + * The hostile mobs within a radius of a player. + * + * @param player the player + * @param radius search radius in blocks + * @return the enemies found + */ + public List nearbyEnemies(Player player, double radius) { + return player.getNearbyEntities(radius, radius, radius).stream() + .filter(Enemy.class::isInstance) + .filter(entity -> !entity.isDead()) + .toList(); + } + + /** + * Tell a player why the warp will not engage. + * + * @param player the player + */ + public void refuse(Player player) { + User.getInstance(player).sendMessage("tradewinds.warp.enemies-near"); + } + + /** + * Any damage closes any open dialog. Deliberately all damage, not just + * mobs: drowning while reading a shop menu should also put the menu away. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDamage(EntityDamageEvent event) { + if (event.getEntity() instanceof Player player && addon.inWorld(player.getWorld())) { + player.closeDialog(); + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index fb1116b..14c3301 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -83,6 +83,11 @@ public List destinations(Player player, IslandSpec origin, double f * Show the warp dialog to a boated player at an island border. */ public void openDialog(Player player, IslandSpec origin) { + // No warping out of a fight - the same rule a bed applies to sleeping + if (addon.getDialogGuard().enemiesNear(player)) { + addon.getDialogGuard().refuse(player); + return; + } double fuelAboard = addon.getFuelService().holdFuel(player); List destinations = destinations(player, origin, fuelAboard); if (destinations.isEmpty()) { @@ -126,6 +131,12 @@ private ActionButton button(Player player, IslandSpec origin, Destination dest) * refunded - see {@link #standStillThen}). */ public void warp(Player player, IslandSpec from, IslandSpec to, int fuelCost) { + // Checked again here, not only at the dialog: a patrol can arrive while + // the menu is open, and the fuel is spent the moment this is allowed + if (addon.getDialogGuard().enemiesNear(player)) { + addon.getDialogGuard().refuse(player); + return; + } TWWarpEvent event = new TWWarpEvent(player, from, to, fuelCost); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) { @@ -163,6 +174,12 @@ private void standStillThen(Player player, Runnable jump, int fuelCost) { user(player).sendMessage("tradewinds.warp.course-broken"); return; } + if (addon.getDialogGuard().enemiesNear(player)) { + // Something reached them while the warp was spinning up + refund(player, fuelCost); + addon.getDialogGuard().refuse(player); + return; + } jump.run(); }, seconds * 20L); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 14ba1ac..54dc50d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -87,6 +87,12 @@ travel: # fuel salvaged). Stops the warp being a panic button in a fight or customs chase. # 0 = instant. Ops and tradewinds.mod.bypassdelays holders always warp instantly. stand-still-seconds: 0 + # Hostile mobs within this many blocks stop a warp engaging, the way monsters + # stop you sleeping in a bed. A warp is not a panic button out of a fight or a + # customs chase - run, fight, or jettison. 0 disables the check. + # The interstice re-engage is always exempt: it is the way OUT of somewhere + # dangerous, and gating it could strand a player for good. + enemy-radius: 12.0 # Warping hurts - it gates the under-equipped. nausea-seconds: 8 blindness-seconds: 3 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 3273796..6b549eb 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -234,3 +234,4 @@ tradewinds: arrived: "The warp releases you at [name]." hold-course: "Hold your course for [seconds] seconds - the warp is spinning up..." course-broken: "You broke course - the warp fizzles out. Fuel salvaged." + enemies-near: "[actionbar]You cannot warp while enemies are close." From f6a3db481de13f52912dc450a7d82f89ac1b6a63 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 09:30:56 -0700 Subject: [PATCH 064/112] Make the Star Chart ephemeral: it exists while you look at it Playtest: running the command again hands out another map, and they could end up littering. Both true, and the old give() was worse than reported - it dropped inventory overflow on the ground, so a full pack meant a map floating in the sea. Rather than making the command idempotent and leaving a permanent item to manage, the chart is now an instrument you consult. It goes into a free hotbar slot which is then selected, so "put it away and it is gone" applies from the moment it arrives. Switching slots reclaims it, dropping destroys it, it is not in death drops, and logging out takes it. Running the command twice reclaims the first, so there is exactly one ever. The slot-change rule only fires when the slot being left held a chart, so a chart sitting elsewhere is never snatched before its owner has looked at it. 209 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 14 ++ docs/PROGRESS.md | 22 +++ .../tradewinds/travel/StarChartService.java | 128 +++++++++++++++++- src/main/resources/locales/en-US.yml | 2 +- 4 files changed, 162 insertions(+), 4 deletions(-) diff --git a/TESTING.md b/TESTING.md index 7c97417..310acec 100644 --- a/TESTING.md +++ b/TESTING.md @@ -913,3 +913,17 @@ two rules. exemption and it matters: it is the way out of somewhere dangerous, and gating it would strand players. - [ ] `travel.warp.enemy-radius: 0` disables the gate (dialogs still close on damage). + +## Star Chart is ephemeral + +- [ ] `/tw starchart` puts the chart straight **into your hand** (a free hotbar slot, + selected for you) rather than somewhere you have to hunt for. +- [ ] **Switch to another hotbar slot: the chart is gone.** Run the command again to + consult it - it is an instrument, not cargo. +- [ ] Run `/tw starchart` twice: you end up with **one** chart, not two. +- [ ] Try to drop it: it vanishes rather than bobbing in the sea. +- [ ] Die holding it: it is **not** in your death drops, and nobody can salvage one. +- [ ] Log out holding it, log back in: gone, and the command still works. +- [ ] Sanity check the point of all this: after a dozen `/tw starchart` calls there + are no stray maps in your inventory, on the ground, or in your hold. +- [ ] With a completely full hotbar it is added anywhere it fits rather than refused. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d5d998a..b6b2bb8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,28 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The Star Chart is an instrument, not cargo (2026-08-01) + +Ben: "When I did starchart I got given another one. I fear these could end up +littering." + +Right on both counts, and the old `give()` was worse than described - it +dropped any inventory overflow on the ground, so a full pack meant a map +floating in the sea. Rather than making the command idempotent and leaving a +permanent item to manage, the chart is now **ephemeral**: it exists only while +you are looking at it. + +It goes straight into a free hotbar slot which is then selected, so the +"put it away and it is gone" rule applies from the moment it arrives. Switching +slots reclaims it, dropping it destroys it, dying does not yield it, and +logging out takes it with you. Running the command twice reclaims the first, so +there is exactly one ever. + +The slot-change rule only fires when the slot being *left* held a chart, so a +chart sitting elsewhere is never snatched before its owner has looked at it. + +209 tests green. + ## Dialogs close under attack, and no warping out of a fight (2026-08-01) Ben: a dialog should exit if you are being attacked, with a bed-style action diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java b/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java index ad143ee..17ced43 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarChartService.java @@ -2,13 +2,21 @@ import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerItemHeldEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.server.MapInitializeEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; import org.bukkit.inventory.meta.MapMeta; import org.bukkit.map.MapView; +import org.bukkit.persistence.PersistentDataType; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.Database; @@ -20,15 +28,25 @@ * {@link StarChartRenderer}. The view's id persists in TWWorldData so the * renderer re-attaches on MapInitializeEvent after restarts - old chart items * keep working forever. + *

+ * The chart is ephemeral: it exists only while you are looking at it. + * Switch to another slot, drop it, die or log out and it is gone. It is an + * instrument you consult, not cargo - and a permanent one would have meant a + * new map item every time the command was run, filling inventories and + * littering the sea with maps nobody wanted. * * @author tastybento */ public class StarChartService implements Listener { + /** Marks an item as a Star Chart, so it can be recognised and reclaimed. */ + private static final String CHART_KEY = "starchart"; + private final TradeWinds addon; private final Database handler; private TWWorldData data; private MapView view; + private NamespacedKey chartKey; public StarChartService(TradeWinds addon) { this.addon = addon; @@ -39,19 +57,123 @@ public StarChartService(TradeWinds addon) { } } + private NamespacedKey key() { + if (chartKey == null) { + chartKey = new NamespacedKey(addon.getPlugin(), CHART_KEY); + } + return chartKey; + } + + /** + * Whether an item is a Star Chart. + * + * @param item the item + * @return true if it is a chart + */ + public boolean isStarChart(ItemStack item) { + return item != null && item.getType() == Material.FILLED_MAP && item.hasItemMeta() + && item.getItemMeta().getPersistentDataContainer().has(key(), PersistentDataType.BYTE); + } + /** - * Give (or hand another copy of) the Star Chart to a player. + * Put a Star Chart in the player's hand. + *

+ * Any chart they already hold is reclaimed first, and the new one goes + * into the held slot so that the "put it away and it is gone" rule applies + * from the moment they receive it. + * + * @param player the player */ public void give(Player player) { + reclaim(player); ItemStack item = new ItemStack(Material.FILLED_MAP); if (item.getItemMeta() instanceof MapMeta meta) { meta.setMapView(chartView()); meta.displayName(User.getInstance(player).getTranslationAsComponent("tradewinds.item.starchart", new String[0])); + meta.getPersistentDataContainer().set(key(), PersistentDataType.BYTE, (byte) 1); item.setItemMeta(meta); } - player.getInventory().addItem(item).values() - .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + PlayerInventory inventory = player.getInventory(); + int slot = freeHotbarSlot(inventory); + if (slot < 0) { + // Every hotbar slot is full: hand it over anyway rather than + // refusing, and let them find it + inventory.addItem(item); + return; + } + inventory.setItem(slot, item); + inventory.setHeldItemSlot(slot); + } + + /** + * A free hotbar slot, preferring the one already in hand. + */ + private int freeHotbarSlot(PlayerInventory inventory) { + if (inventory.getItemInMainHand().getType().isAir()) { + return inventory.getHeldItemSlot(); + } + for (int slot = 0; slot < 9; slot++) { + if (inventory.getItem(slot) == null || inventory.getItem(slot).getType().isAir()) { + return slot; + } + } + return -1; + } + + /** + * Take back every Star Chart a player is carrying. + * + * @param player the player + * @return how many were reclaimed + */ + public int reclaim(Player player) { + int taken = 0; + PlayerInventory inventory = player.getInventory(); + for (int slot = 0; slot < inventory.getSize(); slot++) { + if (isStarChart(inventory.getItem(slot))) { + inventory.setItem(slot, null); + taken++; + } + } + return taken; + } + + /** + * Looking away puts the chart away. Only fires when the slot being left + * actually held one, so a chart sitting in another slot is not snatched + * before its owner has looked at it. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onHeldItemChange(PlayerItemHeldEvent event) { + ItemStack was = event.getPlayer().getInventory().getItem(event.getPreviousSlot()); + if (isStarChart(was) && !isStarChart(event.getPlayer().getInventory().getItem(event.getNewSlot()))) { + reclaim(event.getPlayer()); + } + } + + /** + * Dropping it destroys it rather than leaving a map bobbing in the sea. + */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onDrop(PlayerDropItemEvent event) { + if (isStarChart(event.getItemDrop().getItemStack())) { + event.getItemDrop().remove(); + } + } + + /** + * A chart is an instrument, not cargo: it is not part of anyone's death + * loot, and there is no salvaging one from a wreck. + */ + @EventHandler(priority = EventPriority.HIGHEST) + public void onDeath(PlayerDeathEvent event) { + event.getDrops().removeIf(this::isStarChart); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + reclaim(event.getPlayer()); } private MapView chartView() { diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 6b549eb..ca1d106 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -188,7 +188,7 @@ tradewinds: charted: "[actionbar]Charted [name]!" port-scan: "You copy the harbour charts: [number] islands added to yours." holograms-shown: "Your chart shimmers into the air - row toward a name." - starchart-given: "The Star Chart is yours - hold it to see your world." + starchart-given: "You unroll the Star Chart. Put it away and it is stowed - run the command again whenever you need it." empty: "Your chart is empty - row into an island's waters to chart it." header: "Your chart ([number] islands):" fuel-aboard: "Fuel aboard: [amount] units. Reachable destinations are marked." From bf01998bfe11dd2626ab9d4a6943dc948a1ef2e0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 09:37:45 -0700 Subject: [PATCH 065/112] Stop warp arrivals loading chunks, and never deploy over a running server Server crash: NoClassDefFoundError on IslandPalette inside chunk generation, which Paper escalates to an unrecoverable chunk system failure. The cause was my deploy. The jar was written at 09:30:56 and the class failed to load 45 seconds later, mid-session: the plugin classloader reads classes lazily out of the jar, so overwriting it under a running server invalidates the handle and anything not already loaded is gone. IslandPalette is only touched for dock, plaza or land columns, so it can stay unloaded for a long time - until a warp arrival generated a chunk near an island. scripts/deploy.sh now refuses to install while the server is up, and CLAUDE.md says why. The trace also exposed a real bug. That chunk generation was triggered by SeaArrival.openSeaNear, which read blocks spiralling out to 160 blocks - and reading a block in an ungenerated chunk forces a synchronous load, so one warp arrival could make the main thread generate dozens of chunks before the teleport began. It also meant any generation failure in that spiral took the server down. None of it was necessary: the sea floor and island masks are pure functions of (seed, position), so "is this open water?" is arithmetic. The search now asks the galaxy - floor below sea level, no dock or plaza over the column - and touches no blocks at all. 210 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 10 +++- docs/PROGRESS.md | 30 ++++++++++ scripts/deploy.sh | 27 +++++++++ .../tradewinds/travel/IntersticeService.java | 4 +- .../tradewinds/travel/SeaArrival.java | 60 +++++++++++-------- .../tradewinds/travel/WarpService.java | 4 +- .../tradewinds/travel/SeaArrivalTest.java | 24 ++++++++ 7 files changed, 129 insertions(+), 30 deletions(-) create mode 100755 scripts/deploy.sh diff --git a/CLAUDE.md b/CLAUDE.md index f9356b6..8ec52de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,14 @@ mvn test # all tests mvn test -Dtest=ClassName # one test class ``` -Deploy for in-game testing: copy `target/TradeWinds-*-LOCAL.jar` to -`/Users/ben/Minecraft/26.2/plugins/BentoBox/addons/`. +Deploy for in-game testing: `./scripts/deploy.sh` (builds, then installs to +`/Users/ben/Minecraft/26.2/plugins/BentoBox/addons/`). + +**Never overwrite the jar while the server is running** - the deploy script +refuses to. The plugin classloader reads classes lazily from the jar, so +replacing it invalidates the handle and any not-yet-loaded class throws +`NoClassDefFoundError`. When that lands inside chunk generation, Paper calls it +an unrecoverable chunk system failure and stops the server. ## Project diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index b6b2bb8..f9a1446 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,36 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Server crash: hot-swapped jar, and a chunk-loading warp arrival (2026-08-01) + +`NoClassDefFoundError: IslandPalette` inside chunk generation, which Paper +escalates to an unrecoverable chunk system failure and stops the server. + +**Cause: my deploy.** The jar was written at 09:30:56 and the class failed to +load at 09:31:41, 45 seconds later, mid-session. The plugin classloader reads +classes lazily out of the jar file, so overwriting it under a running server +invalidates the handle and anything not already loaded is simply gone. +`IslandPalette` is only touched when a column turns out to be dock or plaza or +land, so it can go a long time unloaded - and then a warp arrival generated a +chunk near an island and it was needed. `scripts/deploy.sh` now refuses to +install while the server is up, and CLAUDE.md says why. + +**And a real bug it exposed.** The crash landed in chunk generation *triggered +by* `SeaArrival.openSeaNear`, which read blocks - `world.getBlockAt(...)` - +spiralling out to 160 blocks. Reading a block in an ungenerated chunk forces a +synchronous load, so a single warp arrival could make the main thread generate +dozens of chunks in a row before the teleport even began. It also meant a +generation failure anywhere in that spiral took the server with it. + +None of that was necessary: the sea floor and the island masks are pure +functions of (seed, position), so "is this open water?" is arithmetic. The +search now asks the galaxy - floor below sea level, and no dock or plaza +terraformed over the column - and touches no blocks at all. The interstice +passes a null engine: no islands, no docks, and a floor that cannot reach the +surface, so the intended point always serves. + +210 tests green. + ## The Star Chart is an instrument, not cargo (2026-08-01) Ben: "When I did starchart I got given another one. I fear these could end up diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..cd09901 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# Build TradeWinds and install it into the test server - but never over a +# running one. +# +# Overwriting a plugin jar while the server is up invalidates the open jar +# handle that the plugin classloader reads from. Anything not yet loaded then +# fails with NoClassDefFoundError, and if that happens inside chunk generation +# (which runs off the main thread) Paper treats it as an unrecoverable chunk +# system failure and takes the whole server down. That is exactly how the +# 2026-08-01 crash happened: jar written at 09:30:56, IslandPalette failed to +# load at 09:31:41. +# +set -euo pipefail + +ADDONS="/Users/ben/Minecraft/26.2/plugins/BentoBox/addons" +JAR="target/TradeWinds-0.1.0-SNAPSHOT-LOCAL.jar" + +if pgrep -f "paper-26.2.jar" > /dev/null; then + echo "REFUSING TO DEPLOY: the test server is running." + echo "Stop it first - replacing the jar under a live server crashes it." + exit 1 +fi + +mvn -q clean package "$@" +cp "$JAR" "$ADDONS/" +echo "Deployed $(basename "$JAR") to $ADDONS" diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index 929b75b..f254f86 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -145,7 +145,9 @@ public void strand(Player player, IslandSpec from, IslandSpec to) { int z = (int) Math.round(from.centerZ() + (to.centerZ() - from.centerZ()) * fraction); // Open water here too: the interstice has its own sea floor, and // dropping a castaway inside it would be the same suffocation bug - Location target = SeaArrival.openSeaNear(addon.getNetherWorld(), x, z, + // No galaxy in the interstice: no islands, no docks, and a floor that + // cannot reach the surface, so the intended point always serves + Location target = SeaArrival.openSeaNear(null, addon.getNetherWorld(), x, z, addon.getSettings().getIntersticeSeaHeight()); Entity vehicle = player.getVehicle(); diff --git a/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java b/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java index 68be476..780bff0 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java +++ b/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java @@ -5,21 +5,24 @@ import java.util.List; import org.bukkit.Location; -import org.bukkit.Material; import org.bukkit.World; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; + /** * Finds open water to arrive on. *

* A warp used to land the player at a fixed distance from the island centre, - * at sea level, without ever asking what was there. That was survivable while - * coastlines were perfect circles of a known radius - and then ragged coasts - * arrived, headlands started reaching past the arrival ring, and a warp could - * materialise a sailor inside a hillside. The death message reads "suffocated - * in a wall", which is not a sentence any player should have to interpret. + * at sea level, without ever asking what was there - and the quay reaches past + * that ring with its deck at exactly that height, so an unlucky bearing + * materialised a sailor inside the decking ("suffocated in a wall"). *

- * Arrivals are by boat, so the rule is simple: put them on open water, as near - * the intended spot as possible. + * The search asks the galaxy, not the world. Everything about the sea + * floor and the island masks is a pure function of (seed, position), so + * "is this open water?" can be answered by arithmetic - no block reads, and + * therefore no chunk loading. The first cut did read blocks, which meant a + * spiral scan out to 160 blocks could force the main thread to generate + * dozens of chunks one after another before the teleport could even begin. * * @author tastybento */ @@ -57,41 +60,48 @@ public static List searchOffsets(int radius, int step) { } /** - * Whether a column is open water a boat can float on: water at sea level, - * and clear air above it for the sailor's head. + * Whether a column is open water, from the galaxy alone. + *

+ * Two ways a column can fail: the sea floor plus any island lift reaches + * the surface, or a dock or plaza has been terraformed over it - the quay + * is solid to a block above sea level, which is exactly where an arrival + * lands. * - * @param world the world + * @param engine the galaxy * @param x block x * @param z block z - * @param seaHeight the sea surface Y + * @param seaLevel the sea surface Y * @return true if it is somewhere to arrive */ - public static boolean isOpenSea(World world, int x, int z, int seaHeight) { - return world.getBlockAt(x, seaHeight, z).getType() == Material.WATER - && world.getBlockAt(x, seaHeight + 1, z).isEmpty() - && world.getBlockAt(x, seaHeight + 2, z).isEmpty(); + public static boolean isOpenSea(GalaxyEngine engine, int x, int z, int seaLevel) { + return engine.surfaceHeightAt(x, z) < seaLevel && engine.columnPlanAt(x, z).isEmpty(); } /** * The nearest open water to an intended arrival point. * + * @param engine the galaxy, or null for a world it does not describe (the + * interstice, which has no islands and no docks - its floor can + * never reach the surface, so the intended point always serves) * @param world the world to arrive in * @param x intended block x * @param z intended block z - * @param seaHeight the sea surface Y - * @return a location on open water, or the intended point raised above the - * terrain if this whole area is somehow solid + * @param seaLevel the sea surface Y + * @return a location on open water */ - public static Location openSeaNear(World world, int x, int z, int seaHeight) { + public static Location openSeaNear(GalaxyEngine engine, World world, int x, int z, int seaLevel) { + if (engine == null) { + return new Location(world, x + 0.5, seaLevel + 1.0, z + 0.5); + } for (int[] offset : searchOffsets(SEARCH_RADIUS, STEP)) { int cx = x + offset[0]; int cz = z + offset[1]; - if (isOpenSea(world, cx, cz, seaHeight)) { - return new Location(world, cx + 0.5, seaHeight + 1.0, cz + 0.5); + if (isOpenSea(engine, cx, cz, seaLevel)) { + return new Location(world, cx + 0.5, seaLevel + 1.0, cz + 0.5); } } - // Nothing but land for 160 blocks: put them on top of it rather than - // inside it. Better a strange arrival than a suffocation. - return new Location(world, x + 0.5, world.getHighestBlockYAt(x, z) + 1.0, z + 0.5); + // Nothing but land for 160 blocks: arrive above it rather than inside + // it. Better a strange arrival than a suffocation. + return new Location(world, x + 0.5, engine.surfaceHeightAt(x, z) + 1.0, z + 0.5); } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index 14c3301..de115ed 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -235,8 +235,8 @@ private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int f // from the island centre, and since coastlines gained headlands it can // fall on land - which used to materialise the sailor inside a hillside // and kill them ("suffocated in a wall"). - Location target = SeaArrival.openSeaNear(addon.getOverWorld(), arrive[0], arrive[1], - addon.getSettings().getSeaHeight()); + Location target = SeaArrival.openSeaNear(addon.getGalaxyEngine(addon.getOverWorld().getSeed()), + addon.getOverWorld(), arrive[0], arrive[1], addon.getSettings().getSeaHeight()); // Dismount -> teleport player and boat -> re-seat (AcidIsland /ai pattern) Entity vehicle = player.getVehicle(); diff --git a/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java b/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java index 8f20309..0791a3e 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java @@ -1,6 +1,7 @@ package world.bentobox.tradewinds.travel; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -8,6 +9,7 @@ import org.junit.jupiter.api.Test; import world.bentobox.tradewinds.galaxy.ColumnPlan; +import world.bentobox.tradewinds.galaxy.DockPlan; import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandSpec; @@ -87,6 +89,28 @@ void testSearchOffsetsAreOrderedNearestFirst() { assertTrue(offsets.size() > 100, "Too few candidates to find water: " + offsets.size()); } + @Test + void testOpenSeaIsAnswerableFromTheGalaxyAlone() { + // The search must never read blocks. It used to, which meant a spiral + // scan out to 160 blocks could force the main thread to GENERATE dozens + // of chunks before a teleport could begin - and a chunk generation that + // fails takes the whole chunk system down with it. + GalaxyEngine engine = engine(); + IslandSpec island = engine.islandsNear(0, 0, 12_000).iterator().next(); + DockPlan plan = engine.dockPlan(island); + + // Open water well off the island + assertTrue(SeaArrival.isOpenSea(engine, island.centerX() + 900, island.centerZ() + 900, SEA)); + // The island itself is not + assertFalse(SeaArrival.isOpenSea(engine, island.centerX(), island.centerZ(), SEA)); + // Nor is the quay - the deck sits at arrival height, which is the whole + // reason this check exists + int quayX = island.centerX() + (int) Math.round(Math.cos(plan.bearing()) * (plan.dockEnd() - 10)); + int quayZ = island.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * (plan.dockEnd() - 10)); + assertFalse(SeaArrival.isOpenSea(engine, quayX, quayZ, SEA), + "The quay must never be treated as open water"); + } + @Test void testSearchIsWideEnoughToClearAnIsland() { // A headland can reach well past the arrival ring, so the search has to From 063cfe1970aeb430af1dba7b8292ae9602caade5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 09:44:29 -0700 Subject: [PATCH 066/112] Light and lid the interstice, and make the ghasts visible "Very dark and dim... the ghasts I hear - are they really there?" They were, and I had made them invisible. Yesterday's fix for players dying on arrival pushed the spawn distance to 90 blocks so engaging would be a choice, but the monster entity-tracking-range is 48 on a default spigot.yml (96 here), so a ghast at 90-120 blocks is never sent to the client at all - and the same fix stopped giving them a target, so they had no reason to close either. The result was a scream I play myself and nothing else. Distance is now 44, inside tracking range anywhere, with the grace window and the sometimes-nothing roll still making arrival survivable. An open black sky over a black sea reads as unfinished rather than hostile, so the interstice now has a ceiling - netherrack under bedrock, 48 blocks up - and braziers: netherrack outcrops rising out of the water with fire on top, about one chunk in six. Fire on netherrack burns forever and has nothing to spread to. They are the only light out there, and they double as landmarks. 210 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 23 ++++++ docs/PROGRESS.md | 32 ++++++++ .../world/bentobox/tradewinds/Settings.java | 26 ++++++- .../generator/ChunkGeneratorWorld.java | 26 ++++++- .../generator/IntersticeDecorator.java | 77 +++++++++++++++++++ src/main/resources/config.yml | 16 +++- 6 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java diff --git a/TESTING.md b/TESTING.md index 310acec..01de2a0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -927,3 +927,26 @@ two rules. - [ ] Sanity check the point of all this: after a dozen `/tw starchart` calls there are no stray maps in your inventory, on the ground, or in your hold. - [ ] With a completely full hotbar it is added anywhere it fits rather than refused. + +## The interstice: lit, lidded, and ghasts you can actually see + +**Needs fresh interstice chunks** - delete `world/dimensions/minecraft/tradewinds_world_nether`. +Use `/twadmin warpfail ` then `/tw warp` to get there on demand. + +- [ ] **The ghasts are visible.** They were always spawning; at 90 blocks they were + beyond the monster entity-tracking-range (48 on a default spigot.yml, 96 here) + so the client was never sent them. Now ~44 blocks: you see them, hear them, and + choose whether to engage. +- [ ] Some failures still bring nothing at all (`ghast-chance: 0.6`). +- [ ] The 20-second arrival grace still holds - nothing targets or hurts you while + you read the dialog. +- [ ] **There is a ceiling** about 48 blocks above the sea: netherrack with bedrock + on top. Fly up and confirm you cannot leave. +- [ ] **Burning braziers** - netherrack outcrops rising out of the water with fire on + top - appear across the sea, roughly one chunk in six. They light the place and + give you something to steer by. +- [ ] The fires stay lit (netherrack burns forever) and do not spread - there is + nothing out there to catch. +- [ ] Some braziers have glowstone at their base, visible from underwater. +- [ ] Overall: the interstice reads as somewhere hostile you are trapped in, rather + than an unfinished black void. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index f9a1446..7d74871 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,38 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The interstice: lit, lidded, and ghasts you can see (2026-08-01) + +Ben: "very dark and dim... the ghast(s) I hear spawn when I failed - I never see +them, are they really there, or was it just the sound?" + +**They were really there, and I had made them invisible.** Yesterday's fix for +players dying on arrival pushed the spawn distance out to 90 blocks so engaging +would be a choice. But the monster **entity-tracking-range** is 48 on a default +spigot.yml (96 on this server), so a ghast at 90-120 blocks is never sent to the +client at all - and since the same fix stopped giving them a target, they had no +reason to close the distance either. The result was a scream (which I play +myself on arrival) and nothing else, forever. Distance is now 44, comfortably +inside tracking range on any server, with the grace window and the +sometimes-nothing roll still doing the work of making arrival survivable. + +A good reminder that "far enough away to be fair" has an upper bound set by what +the client is ever told about. + +**And the dark.** An open black sky over a black sea reads as unfinished rather +than hostile, so the interstice now has a **ceiling** (netherrack under bedrock, +48 blocks up) and **braziers**: netherrack outcrops rising out of the water with +fire burning on top, roughly one chunk in six. Fire on netherrack burns forever +and there is nothing out there for it to spread to. They are the only light in +the place, and they double as landmarks - somewhere featureless is disorienting +in a way that somewhere dangerous is not. + +**Pitfall:** a careless string replace turned the live config's +`ghast-distance: 90.0` into a stray `.0` appended to the next key +(`brazier-chance: 0.18.0`). Parse the YAML after editing it, every time. + +210 tests green. + ## Server crash: hot-swapped jar, and a chunk-loading warp arrival (2026-08-01) `NoClassDefFoundError: IslandPalette` inside chunk generation, which Paper diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 976421a..1a9dfdc 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -599,17 +599,31 @@ private static Map defaultBasePrices() { @ConfigEntry(path = "interstice.ghast-chance") private double intersticeGhastChance = 0.6; - @ConfigComment("How far away ghasts appear, in blocks. Keep this ABOVE a ghast's own 64-block") - @ConfigComment("detection range: they are not told where the player is, so at this distance") - @ConfigComment("engaging is the player's choice rather than something done to them.") + @ConfigComment("How far away ghasts appear, in blocks. They are not given a target, so this is") + @ConfigComment("how far off they hang until the player does something about them.") + @ConfigComment("Keep it well under the server's monster entity-tracking-range (48 on a default") + @ConfigComment("spigot.yml) or the client is never sent them at all: the first cut used 90 and") + @ConfigComment("the ghasts were real, present, and completely invisible.") @ConfigEntry(path = "interstice.ghast-distance") - private double intersticeGhastDistance = 90; + private double intersticeGhastDistance = 44; @ConfigComment("Seconds after a failed warp during which nothing in the interstice may target") @ConfigComment("or hurt the player - long enough to read the dialog and take the free way out.") @ConfigEntry(path = "interstice.grace-seconds") private int intersticeGraceSeconds = 20; + @ConfigComment("Give the interstice a ceiling this many blocks above its sea. A lid makes the") + @ConfigComment("place feel like somewhere you are trapped rather than an empty void, and stops") + @ConfigComment("anything escaping upward. 0 leaves it open.") + @ConfigEntry(path = "interstice.ceiling-height", needsReset = true) + private int intersticeCeilingHeight = 48; + + @ConfigComment("Chance (0-1) per chunk of a burning netherrack brazier rising out of the") + @ConfigComment("interstice sea. They are the only light out there - without them it is a flat") + @ConfigComment("black nothing, and a player cannot see what is coming.") + @ConfigEntry(path = "interstice.brazier-chance", needsReset = true) + private double intersticeBrazierChance = 0.18; + @ConfigComment("Ghasts spawned around a stranded sailor in the interstice.") @ConfigEntry(path = "interstice.ghasts-min") private int intersticeGhastsMin = 1; @@ -2925,6 +2939,10 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setIntersticeGhastChance(double v) { this.intersticeGhastChance = v; } public double getIntersticeGhastDistance() { return intersticeGhastDistance; } public void setIntersticeGhastDistance(double v) { this.intersticeGhastDistance = v; } + public int getIntersticeCeilingHeight() { return intersticeCeilingHeight; } + public void setIntersticeCeilingHeight(int v) { this.intersticeCeilingHeight = v; } + public double getIntersticeBrazierChance() { return intersticeBrazierChance; } + public void setIntersticeBrazierChance(double v) { this.intersticeBrazierChance = v; } public int getIntersticeGraceSeconds() { return intersticeGraceSeconds; } public void setIntersticeGraceSeconds(int v) { this.intersticeGraceSeconds = v; } public int getIntersticeGhastsMin() { return intersticeGhastsMin; } diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index ee7090c..5ba9a49 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -67,6 +67,8 @@ private record WorldConfig(int seaHeight, int seaFloor, Material waterBlock) { private static final int CRUST_THICKNESS = 5; /** Soil depth under a land surface before it turns to stone. */ private static final int SOIL_THICKNESS = 4; + /** Layers of rock under the interstice's bedrock lid. */ + private static final int ROOF_THICKNESS = 4; private final TradeWinds addon; private final Map seaConfig = new EnumMap<>(Environment.class); @@ -158,6 +160,27 @@ public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, baseTop, floorTops[(x << 4) | z]); } } + if (!overworld) { + roofOver(chunkData, worldInfo, wc); + } + } + + /** + * Lid the interstice. + *

+ * An open sky over a black sea reads as an empty void - somewhere the game + * forgot to finish - rather than somewhere you are trapped. A ceiling gives + * it a shape, and stops anything leaving upward. + */ + private void roofOver(ChunkData chunkData, WorldInfo worldInfo, WorldConfig wc) { + int height = addon.getSettings().getIntersticeCeilingHeight(); + if (height <= 0) { + return; + } + int roof = Math.min(worldInfo.getMaxHeight() - 1, wc.seaHeight() + height); + int underside = Math.max(wc.seaHeight() + 1, roof - ROOF_THICKNESS); + chunkData.setRegion(0, underside, 0, 16, roof, 16, Material.NETHERRACK); + chunkData.setRegion(0, roof, 0, 16, roof + 1, 16, Material.BEDROCK); } /** @@ -387,7 +410,8 @@ public boolean shouldGenerateStructures(@NonNull WorldInfo worldInfo, @NonNull R @Override public List getDefaultPopulators(World world) { if (world.getEnvironment() != Environment.NORMAL) { - return List.of(); + // The interstice gets braziers - the only light in the place + return List.of(new IntersticeDecorator(addon)); } if (decorator == null) { decorator = new IslandDecorator(addon); diff --git a/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java new file mode 100644 index 0000000..5d315b8 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java @@ -0,0 +1,77 @@ +package world.bentobox.tradewinds.generator; + +import java.util.Random; + +import org.bukkit.Material; +import org.bukkit.generator.BlockPopulator; +import org.bukkit.generator.LimitedRegion; +import org.bukkit.generator.WorldInfo; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Braziers in the dark: netherrack outcrops rising out of the interstice sea + * with fire burning on top. + *

+ * The interstice was a flat black nothing - a player could not see the water, + * the horizon, or what was coming at them, which made it read as unfinished + * rather than hostile. These are the only light out there. Fire on netherrack + * burns forever and there is nothing for it to spread to, so they stay lit. + *

+ * They double as landmarks. Somewhere featureless is disorienting in a way that + * somewhere dangerous is not, and a burning rock on the horizon gives a + * castaway something to steer by while they decide what to do. + * + * @author tastybento + */ +public class IntersticeDecorator extends BlockPopulator { + + /** How far a brazier can rise above the waterline. */ + private static final int MAX_RISE = 4; + /** How far below the surface the outcrop is rooted. */ + private static final int ROOT_DEPTH = 3; + + private final TradeWinds addon; + + public IntersticeDecorator(TradeWinds addon) { + this.addon = addon; + } + + @Override + public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int chunkX, int chunkZ, + @NonNull LimitedRegion region) { + double chance = addon.getSettings().getIntersticeBrazierChance(); + if (chance <= 0 || random.nextDouble() >= chance) { + return; + } + int sea = addon.getSettings().getIntersticeSeaHeight(); + int x = (chunkX << 4) + random.nextInt(16); + int z = (chunkZ << 4) + random.nextInt(16); + if (!region.isInRegion(x, sea, z) || region.getType(x, sea, z) != Material.WATER) { + return; + } + int rise = 1 + random.nextInt(MAX_RISE); + int top = sea + rise; + // A squat outcrop: widest at the waterline, tapering as it rises, so it + // reads as a rock rather than a pillar + for (int y = sea - ROOT_DEPTH; y <= top; y++) { + int spread = y <= sea ? 1 : 0; + for (int dx = -spread; dx <= spread; dx++) { + for (int dz = -spread; dz <= spread; dz++) { + if (region.isInRegion(x + dx, y, z + dz)) { + region.setType(x + dx, y, z + dz, Material.NETHERRACK); + } + } + } + } + // The flame, and a little glow under the waterline so the rock is + // visible from below as well as across the water + if (region.isInRegion(x, top + 1, z)) { + region.setType(x, top + 1, z, Material.FIRE); + } + if (random.nextBoolean() && region.isInRegion(x, sea - ROOT_DEPTH, z)) { + region.setType(x, sea - ROOT_DEPTH, z, Material.GLOWSTONE); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 54dc50d..2542883 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -317,10 +317,18 @@ interstice: # are just dark water and a long silence - unsettling in its own right, and it # means a failed warp is not automatically a fight. ghast-chance: 0.6 - # How far away ghasts appear, in blocks. Keep ABOVE a ghast's own 64-block - # detection range: they are not told where the player is, so at this distance - # engaging is the player's choice rather than something done to them. - ghast-distance: 90 + # How far away ghasts appear, in blocks. They are not given a target, so this is + # how far off they hang until the player does something about them. Keep it well + # under the server's monster entity-tracking-range (48 on a default spigot.yml) or + # the client is never sent them at all - at 90 they were real, present and + # completely invisible. + ghast-distance: 44 + # Give the interstice a ceiling this many blocks above its sea. A lid makes it feel + # like somewhere you are trapped rather than an empty void. 0 leaves it open. + ceiling-height: 48 + # Chance per chunk of a burning netherrack brazier rising out of the interstice + # sea. They are the only light out there - without them it is a flat black nothing. + brazier-chance: 0.18 # Seconds after a failed warp during which nothing in the interstice may target or # hurt the player - long enough to read the dialog and take the free way out. grace-seconds: 20 From c13a3027da63c284c4a1583afe15a4782fcba1d2 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 10:29:27 -0700 Subject: [PATCH 067/112] Bring the ghasts in, let them hunt, and let the dialog be dismissed Still too far after the last fix, for two reasons. The spawn code added random*30 blocks on top of the configured distance, quietly undoing the setting - a base of 44 was arriving as far out as 74. It is now a tight band around the value and the default is 28. And nothing ever re-aimed them. Spawning without a target stopped arrival being an ambush, but they then drifted and the encounter never happened at all. Once the arrival grace expires, anything nearby acquires the castaway: the free way out is always on offer, so staying is the choice that carries consequences. The re-engage dialog had exactly one button, so the only way to put it down was to take the warp. It now has a "Stay a while" exit - declining costs nothing, since the offer repeats and the fuel is already spent. 210 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 12 ++++-- docs/PROGRESS.md | 13 ++++++ .../world/bentobox/tradewinds/Settings.java | 7 ++-- .../tradewinds/travel/IntersticeService.java | 42 +++++++++++++++++-- src/main/resources/config.yml | 12 +++--- src/main/resources/locales/en-US.yml | 2 + 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/TESTING.md b/TESTING.md index 01de2a0..e4d433a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -933,10 +933,14 @@ two rules. **Needs fresh interstice chunks** - delete `world/dimensions/minecraft/tradewinds_world_nether`. Use `/twadmin warpfail ` then `/tw warp` to get there on demand. -- [ ] **The ghasts are visible.** They were always spawning; at 90 blocks they were - beyond the monster entity-tracking-range (48 on a default spigot.yml, 96 here) - so the client was never sent them. Now ~44 blocks: you see them, hear them, and - choose whether to engage. +- [ ] **The ghasts are visible and close enough to identify** - about 28 blocks, in + a tight band (the spread used to add up to 30 blocks on top of the setting, + which quietly undid it: a base of 44 arrived as far out as 74). +- [ ] **They come for you when the grace runs out.** They arrive without a target so + landing is not an ambush, but after 20 seconds anything nearby acquires you. + Previously they never re-aimed at all, so they drifted and nothing happened. +- [ ] The re-engage dialog has a **"Stay a while"** exit button. Declining costs + nothing - the offer comes round again and the fuel is already spent. - [ ] Some failures still bring nothing at all (`ghast-chance: 0.6`). - [ ] The 20-second arrival grace still holds - nothing targets or hurts you while you read the dialog. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7d74871..48e1a09 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -21,6 +21,19 @@ sometimes-nothing roll still doing the work of making arrival survivable. A good reminder that "far enough away to be fair" has an upper bound set by what the client is ever told about. +**Still too far (same day).** Two more faults behind that. The spawn code added +`random * 30` blocks on top of the configured distance, which quietly undid the +setting - a base of 44 was arriving as far out as 74. It is now a tight band +around the value (0.85-1.15x), and the default is 28. And nothing ever re-aimed +the ghasts: spawning them without a target stopped arrival being an ambush, but +they then drifted and the encounter simply never happened. Once the arrival +grace expires, anything nearby now acquires the castaway - the free way out is +always on offer, so staying is the choice that carries consequences. + +The re-engage dialog also gained a **"Stay a while"** exit. It had exactly one +button, so the only way to put it down was to take the warp - and a player who +wants to look at the sea first should be able to. + **And the dark.** An open black sky over a black sea reads as unfinished rather than hostile, so the interstice now has a **ceiling** (netherrack under bedrock, 48 blocks up) and **braziers**: netherrack outcrops rising out of the water with diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 1a9dfdc..ace5563 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -599,13 +599,14 @@ private static Map defaultBasePrices() { @ConfigEntry(path = "interstice.ghast-chance") private double intersticeGhastChance = 0.6; - @ConfigComment("How far away ghasts appear, in blocks. They are not given a target, so this is") - @ConfigComment("how far off they hang until the player does something about them.") + @ConfigComment("How far away ghasts appear, in blocks - close enough to see and identify, far") + @ConfigComment("enough to decide about. They arrive without a target and acquire one only when") + @ConfigComment("the arrival grace runs out, so a castaway can always leave before it starts.") @ConfigComment("Keep it well under the server's monster entity-tracking-range (48 on a default") @ConfigComment("spigot.yml) or the client is never sent them at all: the first cut used 90 and") @ConfigComment("the ghasts were real, present, and completely invisible.") @ConfigEntry(path = "interstice.ghast-distance") - private double intersticeGhastDistance = 44; + private double intersticeGhastDistance = 28; @ConfigComment("Seconds after a failed warp during which nothing in the interstice may target") @ConfigComment("or hurt the player - long enough to read the dialog and take the free way out.") diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index f254f86..9eacbda 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -192,8 +192,11 @@ private void spawnGhasts(Player player, Location around) { double near = addon.getSettings().getIntersticeGhastDistance(); for (int i = 0; i < count; i++) { double angle = Math.random() * Math.PI * 2; - double distance = near + Math.random() * 30; - Location spot = around.clone().add(Math.cos(angle) * distance, 10 + Math.random() * 10, + // A tight band around the configured distance. This used to add up + // to 30 blocks on top of it, which quietly undid the setting: at a + // base of 44 they were arriving as far out as 74. + double distance = near * (0.85 + Math.random() * 0.3); + Location spot = around.clone().add(Math.cos(angle) * distance, 6 + Math.random() * 8, Math.sin(angle) * distance); Entity ghast = around.getWorld().spawnEntity(spot, EntityType.GHAST); if (ghast instanceof Ghast g) { @@ -239,6 +242,29 @@ private void tick() { lastPrompt.put(player.getUniqueId(), now); openReEngageDialog(player); } + hunt(); + } + + /** + * Once a castaway's grace has run out, whatever is out there notices them. + *

+ * Ghasts are spawned without a target so that arriving is not an ambush, + * but nothing ever re-aimed them - so they drifted, and the encounter + * simply never happened. The free way out is always on offer; staying is + * the choice that has consequences. + */ + private void hunt() { + for (Player player : addon.getNetherWorld().getPlayers()) { + if (isInGrace(player.getUniqueId()) || player.isDead() + || player.getGameMode() != org.bukkit.GameMode.SURVIVAL) { + continue; + } + double radius = addon.getSettings().getIntersticeGhastDistance() * 2; + player.getNearbyEntities(radius, radius, radius).stream() + .filter(Ghast.class::isInstance).map(Ghast.class::cast) + .filter(ghast -> ghast.getTarget() == null) + .forEach(ghast -> ghast.setTarget(player)); + } } /** @@ -264,7 +290,17 @@ public void openReEngageDialog(Player player) { .body(java.util.List.of(DialogBody.plainMessage( user.getTranslationAsComponent("tradewinds.ui.interstice.body", NO_VARS)))) .build()) - .type(DialogType.multiAction(java.util.List.of(engage)).columns(1).build())); + // A way to put the dialog down. The offer repeats every + // prompt-seconds and the fuel is already spent, so declining + // costs nothing - but without an exit the only way to look at + // the sea was to take the warp. + .type(DialogType.multiAction(java.util.List.of(engage)) + .exitAction(ActionButton.builder( + user.getTranslationAsComponent("tradewinds.ui.interstice.stay", NO_VARS)) + .tooltip(user.getTranslationAsComponent( + "tradewinds.ui.interstice.stay-tooltip", NO_VARS)) + .width(320).build()) + .columns(1).build())); player.showDialog(dialog); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 2542883..fc42d31 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -317,12 +317,12 @@ interstice: # are just dark water and a long silence - unsettling in its own right, and it # means a failed warp is not automatically a fight. ghast-chance: 0.6 - # How far away ghasts appear, in blocks. They are not given a target, so this is - # how far off they hang until the player does something about them. Keep it well - # under the server's monster entity-tracking-range (48 on a default spigot.yml) or - # the client is never sent them at all - at 90 they were real, present and - # completely invisible. - ghast-distance: 44 + # How far away ghasts appear, in blocks - close enough to see and identify, far + # enough to decide about. They arrive without a target and acquire one only when + # the arrival grace runs out, so a castaway can always leave before it starts. + # Keep it under the server's monster entity-tracking-range (48 on a default + # spigot.yml) or the client is never sent them at all. + ghast-distance: 28 # Give the interstice a ceiling this many blocks above its sea. A lid makes it feel # like somewhere you are trapped rather than an empty void. 0 leaves it open. ceiling-height: 48 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index ca1d106..d63fe30 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -176,6 +176,8 @@ tradewinds: body: "Dark water, and something screaming above it. Your course is still paid for." re-engage: "Re-engage the warp to [name]" re-engage-tooltip: "Free - the fuel was spent when you first engaged" + stay: "Stay a while" + stay-tooltip: "Close this. The offer comes round again, and the fuel is already spent." item: pouch: "Trading Pouch" pouch-lore: "Cargo space. Stow goods to sell them." From e2f747d69a3241b61f9f1b86cf99629e4ae7ec2d Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 10:43:50 -0700 Subject: [PATCH 068/112] Fix silent ghast spawn failure, and log the interstice arrival "The ghasts are still not there." The symptom pointed at it: the arrival sound and message play, and both happen BEFORE spawnGhasts. Everything after it was stopping. The arrival ran inside a teleport future's continuation, which is not guaranteed to be on the main thread - and spawning an entity off-thread throws "Asynchronous entity add!", which a CompletableFuture then swallows whole. The boat re-seat three lines above already hopped back to the main thread with runTask, which was the clue. So the throw silently killed the ghasts AND the warp-failed event, leaving only the sound. The arrival now runs explicitly on the main thread, is wrapped in a try/catch that logs, and the teleport future has an exceptionally() handler so a failure can never be silent again. Logging as asked: the player's stranding coordinates and world, each ghast's coordinates and its distance from the player, how many of the intended count actually spawned, and a distinct line when the ghast-chance roll means nothing was coming. Ghast spawns are also now clamped clear of the new ceiling and the water, since a ghast is four blocks across. 210 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- .../tradewinds/travel/IntersticeService.java | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index 9eacbda..8aa9c5f 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -49,6 +49,9 @@ public class IntersticeService { */ private static final String[] NO_VARS = new String[0]; + /** Room a ghast needs under the interstice ceiling - they are 4 blocks tall. */ + private static final int GHAST_CLEARANCE = 6; + /** Player -> the destination cell they are still owed, free of charge. */ private final Map pendingDestination = new ConcurrentHashMap<>(); private final Map lastPrompt = new ConcurrentHashMap<>(); @@ -154,7 +157,26 @@ public void strand(Player player, IslandSpec from, IslandSpec to) { if (vehicle != null) { player.leaveVehicle(); } - Util.teleportAsync(player, target).thenRun(() -> { + // Everything after the teleport runs on the MAIN THREAD, explicitly. + // A teleport future's continuation is not guaranteed to, and spawning + // an entity off-thread throws "Asynchronous entity add!" - an exception + // a CompletableFuture then swallows whole. That is why the arrival + // sound played and the ghasts never appeared: the throw took out + // everything after it, silently, including the warp-failed event. + Util.teleportAsync(player, target) + .thenRun(() -> Bukkit.getScheduler().runTask(addon.getPlugin(), + () -> onArrival(player, vehicle, target, from, to))) + .exceptionally(error -> { + addon.logError("Interstice teleport failed for " + player.getName() + ": " + error); + return null; + }); + } + + /** + * The arrival itself, on the main thread. + */ + private void onArrival(Player player, Entity vehicle, Location target, IslandSpec from, IslandSpec to) { + try { if (vehicle instanceof Boat boat && boat.isValid()) { boat.teleportAsync(target).thenRun(() -> Bukkit.getScheduler().runTask(addon.getPlugin(), () -> boat.addPassenger(player))); @@ -165,7 +187,11 @@ public void strand(Player player, IslandSpec from, IslandSpec to) { arrivals.put(player.getUniqueId(), System.currentTimeMillis()); spawnGhasts(player, target); Bukkit.getPluginManager().callEvent(new TWWarpFailedEvent(player, from, to)); - }); + } catch (Exception e) { + // Never let an arrival fail silently again + addon.logError("Interstice arrival failed for " + player.getName() + ": " + e); + e.printStackTrace(); + } } /** @@ -184,12 +210,15 @@ public void strand(Player player, IslandSpec from, IslandSpec to) { */ private void spawnGhasts(Player player, Location around) { if (Math.random() >= addon.getSettings().getIntersticeGhastChance()) { + addon.log("Interstice: " + player.getName() + " stranded at " + describe(around) + + " - nothing came (ghast-chance roll)"); return; // Dark water and nothing in it. The interstice is unsettling enough. } int min = addon.getSettings().getIntersticeGhastsMin(); int count = min + (int) (Math.random() * Math.max(1, addon.getSettings().getIntersticeGhastsMax() - min + 1)); double near = addon.getSettings().getIntersticeGhastDistance(); + int spawned = 0; for (int i = 0; i < count; i++) { double angle = Math.random() * Math.PI * 2; // A tight band around the configured distance. This used to add up @@ -198,13 +227,34 @@ private void spawnGhasts(Player player, Location around) { double distance = near * (0.85 + Math.random() * 0.3); Location spot = around.clone().add(Math.cos(angle) * distance, 6 + Math.random() * 8, Math.sin(angle) * distance); + // A ghast is 4 blocks across, and the interstice now has a lid: + // keep it clear of both the ceiling and the water + int ceiling = addon.getSettings().getIntersticeCeilingHeight(); + int sea = addon.getSettings().getIntersticeSeaHeight(); + if (ceiling > 0) { + spot.setY(Math.min(spot.getY(), (double) sea + ceiling - GHAST_CLEARANCE)); + } + spot.setY(Math.max(spot.getY(), sea + 3.0)); Entity ghast = around.getWorld().spawnEntity(spot, EntityType.GHAST); if (ghast instanceof Ghast g) { // Deliberately NOT targeted: let the player decide whether this // is a fight g.setRemoveWhenFarAway(true); + spawned++; } + addon.log("Interstice: ghast spawned at " + describe(spot) + " (" + + (int) spot.distance(around) + " blocks from the player, alive=" + + (ghast != null && ghast.isValid()) + ")"); } + addon.log("Interstice: " + player.getName() + " stranded at " + describe(around) + " in " + + around.getWorld().getName() + " - " + spawned + " of " + count + " ghasts spawned"); + } + + /** + * Compact coordinates for the console. + */ + private static String describe(Location location) { + return location.getBlockX() + "," + location.getBlockY() + "," + location.getBlockZ(); } /** From 0e06c5f935a1f45659c11090c576f6a02b626223 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 11:04:26 -0700 Subject: [PATCH 069/112] Stop the interstice ghasts despawning the moment they arrive The logging paid off: "2 of 2 ghasts spawned" but alive=false on both. Not BentoBox cancelling - World.spawnEntity fires CreatureSpawnEvent with reason CUSTOM, which its MobSpawnListener ignores. It was mine. setRemoveWhenFarAway(true) marks a mob as despawnable, and they arrive at 28-32 blocks - right on the 32-block random-despawn boundary. A step in the wrong direction and the game deleted them, seconds after they appeared and long before anyone could see one. They now persist, and cleanup is ours: they carry a PDC tag and the tick sweeps away any with no player within 160 blocks, so they cannot accumulate. The head count also repeats five seconds after spawning, since "spawned" only ever meant the call returned - anything that removes them does so afterwards, and silently. 210 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- .../tradewinds/travel/IntersticeService.java | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index 8aa9c5f..8cc6ff2 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -1,5 +1,6 @@ package world.bentobox.tradewinds.travel; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -7,6 +8,7 @@ import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.NamespacedKey; import org.bukkit.Particle; import org.bukkit.Sound; import org.bukkit.entity.Boat; @@ -14,6 +16,7 @@ import org.bukkit.entity.EntityType; import org.bukkit.entity.Ghast; import org.bukkit.entity.Player; +import org.bukkit.persistence.PersistentDataType; import org.bukkit.scheduler.BukkitTask; import io.papermc.paper.dialog.Dialog; @@ -49,6 +52,9 @@ public class IntersticeService { */ private static final String[] NO_VARS = new String[0]; + /** Ghasts spawned by the current stranding, for the follow-up head count. */ + private final List spawnedGhasts = new java.util.ArrayList<>(); + /** Room a ghast needs under the interstice ceiling - they are 4 blocks tall. */ private static final int GHAST_CLEARANCE = 6; @@ -62,6 +68,7 @@ public class IntersticeService { private final TradeWinds addon; private BukkitTask task; + private NamespacedKey intersticeKey; public IntersticeService(TradeWinds addon) { this.addon = addon; @@ -238,16 +245,60 @@ private void spawnGhasts(Player player, Location around) { Entity ghast = around.getWorld().spawnEntity(spot, EntityType.GHAST); if (ghast instanceof Ghast g) { // Deliberately NOT targeted: let the player decide whether this - // is a fight - g.setRemoveWhenFarAway(true); + // is a fight. But they must NOT despawn: they arrive right at + // the 32-block random-despawn boundary, so removeWhenFarAway + // was quietly deleting them moments after they appeared. + // Cleanup is ours instead - see sweep(). + g.setRemoveWhenFarAway(false); + g.getPersistentDataContainer().set(intersticeKey(), PersistentDataType.BYTE, (byte) 1); spawned++; + spawnedGhasts.add(g); } addon.log("Interstice: ghast spawned at " + describe(spot) + " (" - + (int) spot.distance(around) + " blocks from the player, alive=" - + (ghast != null && ghast.isValid()) + ")"); + + (int) spot.distance(around) + " blocks from the player)"); } addon.log("Interstice: " + player.getName() + " stranded at " + describe(around) + " in " + around.getWorld().getName() + " - " + spawned + " of " + count + " ghasts spawned"); + // Check again shortly: "spawned" only means the call returned. Anything + // that removes them does so afterwards, and silently. + List watch = List.copyOf(spawnedGhasts); + spawnedGhasts.clear(); + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { + long alive = watch.stream().filter(Entity::isValid).count(); + addon.log("Interstice: " + alive + " of " + watch.size() + + " ghasts still present 5s after spawning"); + }, 100L); + } + + /** + * Marks a mob as one the interstice put there, so it can be cleaned up. + * + * @return the PDC key + */ + private NamespacedKey intersticeKey() { + if (intersticeKey == null) { + intersticeKey = new NamespacedKey(addon.getPlugin(), "interstice"); + } + return intersticeKey; + } + + /** + * Clear out ghasts nobody is left to be menaced by. + *

+ * They no longer despawn on their own - being spawned right at the + * 32-block random-despawn boundary was deleting them moments after they + * appeared - so the tidying is ours to do. + */ + private void sweep() { + for (Entity entity : addon.getNetherWorld().getEntities()) { + if (!entity.getPersistentDataContainer().has(intersticeKey(), PersistentDataType.BYTE)) { + continue; + } + boolean watched = entity.getNearbyEntities(160, 160, 160).stream().anyMatch(Player.class::isInstance); + if (!watched) { + entity.remove(); + } + } } /** @@ -293,6 +344,7 @@ private void tick() { openReEngageDialog(player); } hunt(); + sweep(); } /** From cdc8d9859063eb4f56bdb6488be68d3115177729 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 11:13:55 -0700 Subject: [PATCH 070/112] Search pockets for contraband, and stop patrols ambushing "Will any sugar in any of my inventory slots be checked?" It was not - only the hold. That looked principled, since cargo means the hold everywhere else, but it handed smugglers a free pass: tip the sugar into your pockets before the border, cross, stow it again. The mechanic bypassed with two inventory clicks. Customs now search hold and pockets, and confiscation takes both. "The guardians were instantly on me." The patrol surfaced 22 blocks away and a guardian's laser reaches 15, so it fired before the warning had finished printing. Now 45 blocks, and the scatter cannot pull units back inside that. "I warped really close to the island." Measured: 37 of 864 approach bearings put the arrival on or beside land, because a warped coastline reaches the 130-block ring. The suffocation fix searched every direction, which corrects by moving the sailor inward into a bay - the exact symptom. Arrivals now step outward along the approach only, and want elbow room rather than one water block. Worst push 20 blocks. 211 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 16 +++++ docs/PROGRESS.md | 28 +++++++++ .../world/bentobox/tradewinds/Settings.java | 8 +++ .../tradewinds/crime/CustomsService.java | 45 +++++++++++--- .../tradewinds/crime/PoliceDispatch.java | 22 +++++-- .../tradewinds/travel/SeaArrival.java | 59 +++++++++++++++++++ .../tradewinds/travel/WarpService.java | 7 ++- src/main/resources/config.yml | 4 ++ .../tradewinds/travel/SeaArrivalTest.java | 31 ++++++++++ 9 files changed, 204 insertions(+), 16 deletions(-) diff --git a/TESTING.md b/TESTING.md index e4d433a..aef5f75 100644 --- a/TESTING.md +++ b/TESTING.md @@ -954,3 +954,19 @@ Use `/twadmin warpfail ` then `/tw warp` to get there on demand. - [ ] Some braziers have glowstone at their base, visible from underwater. - [ ] Overall: the interstice reads as somewhere hostile you are trapped in, rather than an unfinished black void. + +## Contraband: pockets count, and the patrol gives you room + +- [ ] **Sugar in your normal inventory slots is scanned too**, not just the hold. + Try the obvious dodge: move the sugar from a pouch into your pockets, cross a + border, stow it again. You are still caught. (Hold-only scanning made that a + free pass.) +- [ ] Being caught seizes contraband from **both** the hold and the pockets - check + the item count in the message against everything you were carrying. +- [ ] A patrol now surfaces about **45 blocks** away, comfortably beyond a guardian's + 15-block laser, so there is a real moment to turn and run. They should not be + hitting you before the warning has finished printing. +- [ ] Warp into an island: you arrive **clear of the shore**, not on the beach or in + a bay. (About 4% of approach bearings used to land on or beside land, because a + warped coastline can reach the arrival ring; arrivals now step outward along + the approach, never sideways or inward.) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 48e1a09..629aa86 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,34 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Contraband in pockets, and patrols that ambush (2026-08-01) + +Two from the same playtest. + +**"Will any sugar in any of my inventory slots be checked?"** It was not - only +the hold. That read as principled, since cargo means the hold everywhere else +in this game, but it handed smugglers a free pass: tip the sugar into your +pockets before the border, cross, stow it again on the far side. The whole +mechanic bypassed with two inventory clicks. Customs now search hold *and* +pockets, and confiscation takes from both. A customs officer searches the +sailor, not just the cargo manifest. + +**"The guardians were instantly on me."** The patrol surfaced 22 blocks away +and a guardian's laser reaches 15, so it was firing before the warning had +finished printing. That is an ambush, not the decision window section 6 asks +for. Now 45 blocks (`illegal-trade.patrol-distance`), and the scatter around +that point can no longer pull units back inside it either. + +**"I warped really close to the island too, which seems odd."** Measured: 37 of +864 approach bearings (4.3%) put the arrival on or beside land, because a warped +coastline can now reach the 130-block arrival ring. The fix from the +suffocation bug searched for water in *every* direction, which happily corrects +by moving the sailor inward into a bay - the exact symptom. Arrivals now step +**outward** along the approach bearing only, and require elbow room rather than +any single water block. Worst case push: 20 blocks. + +211 tests green. + ## The interstice: lit, lidded, and ghasts you can see (2026-08-01) Ben: "very dark and dim... the ghast(s) I hear spawn when I failed - I never see diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index ace5563..a739c01 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -731,6 +731,12 @@ private static Map defaultEncounterChance() { @ConfigEntry(path = "illegal-trade.patrol-size") private Map patrolSize = defaultPatrolSize(); + @ConfigComment("How far off a customs patrol surfaces, in blocks. Must clear a guardian's") + @ConfigComment("15-block laser by a margin or the chase is an ambush - at 22 they were firing") + @ConfigComment("before the warning had finished printing. The decision window needs a decision.") + @ConfigEntry(path = "illegal-trade.patrol-distance") + private double patrolDistance = 45.0; + @ConfigComment("How close a patrol unit must get to make the arrest, in blocks.") @ConfigEntry(path = "illegal-trade.caught-radius") private double caughtRadius = 4.0; @@ -2982,6 +2988,8 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setFleeFlagMinutes(int v) { this.fleeFlagMinutes = v; } public Map getPatrolSize() { return patrolSize; } public void setPatrolSize(Map v) { this.patrolSize = v; } + public double getPatrolDistance() { return patrolDistance; } + public void setPatrolDistance(double v) { this.patrolDistance = v; } public double getCaughtRadius() { return caughtRadius; } public void setCaughtRadius(double v) { this.caughtRadius = v; } public int getChaseBreakOffDistance() { return chaseBreakOffDistance; } diff --git a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java index edf5e26..ed9a298 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java +++ b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java @@ -115,19 +115,40 @@ public boolean isContraband(Material material) { } /** - * How much contraband a player is carrying in their hold. Loose pockets do - * not count: like everything else in this game, cargo means the hold - * (spec principle 1). + * How much contraband a player is carrying, anywhere on them. + *

+ * Hold and pockets. Scanning only the hold looked principled - + * cargo means the hold everywhere else in this game - but it handed + * smugglers a free pass: tip the sugar into your pockets before the border, + * cross, and stow it again on the far side. A customs officer searches the + * sailor, not just the cargo manifest. * * @param player the player - * @return total contraband items aboard + * @return total contraband items on them */ public int contrabandAboard(Player player) { int total = 0; for (String name : contrabandNames()) { Material material = Material.matchMaterial(name); - if (material != null) { - total += addon.getHoldService().count(player, material, stack -> true); + if (material == null) { + continue; + } + total += addon.getHoldService().count(player, material, stack -> true); + total += loose(player, material); + } + return total; + } + + /** + * Contraband in the player's own inventory slots, ignoring the hold - the + * hold is counted separately, and a pouch sitting in a slot must not be + * counted twice. + */ + private int loose(Player player, Material material) { + int total = 0; + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getType() == material) { + total += stack.getAmount(); } } return total; @@ -281,8 +302,16 @@ private int confiscate(Player player) { int seized = 0; for (String name : contrabandNames()) { Material material = Material.matchMaterial(name); - if (material != null) { - seized += addon.getHoldService().remove(player, material, Integer.MAX_VALUE, stack -> true); + if (material == null) { + continue; + } + seized += addon.getHoldService().remove(player, material, Integer.MAX_VALUE, stack -> true); + // Pockets too, or the search that found it could not take it + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getType() == material) { + seized += stack.getAmount(); + stack.setAmount(0); + } } } return seized; diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java index e9300c7..6fbd779 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java @@ -35,15 +35,25 @@ */ public class PoliceDispatch { - /** How far ahead of the smuggler the patrol surfaces, in blocks. */ - private static final double INTERCEPT_DISTANCE = 22.0; - private final TradeWinds addon; public PoliceDispatch(TradeWinds addon) { this.addon = addon; } + /** + * How far ahead of the smuggler the patrol surfaces, in blocks. + *

+ * This has to clear a guardian's 15-block laser by a margin, or a + * "chase" is just an ambush: at 22 they were firing before the warning + * message had finished printing. The point of the decision window is that + * there is a decision. + */ + private double interceptDistance() { + return Math.max(minimumStandoff(), addon.getSettings().getPatrolDistance()); + } + + /** * Send a patrol after a player. Units surface between the smuggler and the * island they were trying to enter, so running for open sea is a real @@ -62,8 +72,8 @@ public List dispatch(Player player, IslandSpec island) { if (toIsland.lengthSquared() < 0.01) { toIsland = new Vector(1, 0, 0); } - Location spot = openWater(from.clone().add(toIsland.normalize().multiply(INTERCEPT_DISTANCE)), from, - minimumStandoff()); + Location spot = openWater(from.clone().add(toIsland.normalize().multiply(interceptDistance())), from, + interceptDistance() * 0.75); if (spot == null) { // Ashore in the market: there is no water to launch a patrol from, // and a guardian spawned on a plaza just flops about. The caller @@ -72,7 +82,7 @@ public List dispatch(Player player, IslandSpec island) { } for (PoliceUnit kind : PoliceRoster.forCustoms(count)) { Location at = openWater(spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3), from, - minimumStandoff()); + interceptDistance() * 0.75); Entity unit = at == null ? null : spawn(at, entityType(kind), player); if (unit != null) { units.add(unit); diff --git a/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java b/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java index 780bff0..9f5c28c 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java +++ b/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java @@ -32,6 +32,8 @@ public final class SeaArrival { private static final int SEARCH_RADIUS = 160; /** Step between rings - a boat-width apart is fine and keeps this cheap. */ private static final int STEP = 4; + /** How much clear water an arrival wants around it, in blocks. */ + private static final int CLEARANCE = 8; private SeaArrival() { // Static use only @@ -77,6 +79,63 @@ public static boolean isOpenSea(GalaxyEngine engine, int x, int z, int seaLevel) return engine.surfaceHeightAt(x, z) < seaLevel && engine.columnPlanAt(x, z).isEmpty(); } + /** + * Open water at or beyond an intended arrival distance, stepping outward + * along the approach bearing. + *

+ * Warps arrive on a ring a fixed distance from the island centre, and a + * ragged coast can reach that far - so the nominal point can land on the + * beach. Searching in every direction would happily fix that by moving the + * sailor inward into a bay, which is how a warp ends up putting + * someone on top of an island. Outward is the only direction that helps. + * + * @param engine the galaxy + * @param world the world to arrive in + * @param centerX island centre x + * @param centerZ island centre z + * @param x intended block x + * @param z intended block z + * @param seaLevel the sea surface Y + * @return a location on open water + */ + public static Location openSeaOutward(GalaxyEngine engine, World world, int centerX, int centerZ, int x, + int z, int seaLevel) { + double dx = (double) x - centerX; + double dz = (double) z - centerZ; + double length = Math.hypot(dx, dz); + if (engine == null || length < 1) { + return openSeaNear(engine, world, x, z, seaLevel); + } + double ux = dx / length; + double uz = dz / length; + for (int out = 0; out <= SEARCH_RADIUS; out += STEP) { + int cx = centerX + (int) Math.round(ux * (length + out)); + int cz = centerZ + (int) Math.round(uz * (length + out)); + if (isClearWater(engine, cx, cz, seaLevel)) { + return new Location(world, cx + 0.5, seaLevel + 1.0, cz + 0.5); + } + } + return openSeaNear(engine, world, x, z, seaLevel); + } + + /** + * Open water with elbow room - not a one-block puddle between two + * headlands, which is technically water and no use to a boat. + */ + private static boolean isClearWater(GalaxyEngine engine, int x, int z, int seaLevel) { + if (!isOpenSea(engine, x, z, seaLevel)) { + return false; + } + for (int dx = -CLEARANCE; dx <= CLEARANCE; dx += CLEARANCE) { + for (int dz = -CLEARANCE; dz <= CLEARANCE; dz += CLEARANCE) { + if (!isOpenSea(engine, x + dx, z + dz, seaLevel)) { + return false; + } + } + } + return true; + } + /** * The nearest open water to an intended arrival point. * diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index de115ed..4b32d48 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -235,8 +235,11 @@ private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int f // from the island centre, and since coastlines gained headlands it can // fall on land - which used to materialise the sailor inside a hillside // and kill them ("suffocated in a wall"). - Location target = SeaArrival.openSeaNear(addon.getGalaxyEngine(addon.getOverWorld().getSeed()), - addon.getOverWorld(), arrive[0], arrive[1], addon.getSettings().getSeaHeight()); + // Outward from the island, never inward: a ragged coast can reach the + // arrival ring, and correcting sideways would drop the sailor in a bay + Location target = SeaArrival.openSeaOutward(addon.getGalaxyEngine(addon.getOverWorld().getSeed()), + addon.getOverWorld(), to.centerX(), to.centerZ(), arrive[0], arrive[1], + addon.getSettings().getSeaHeight()); // Dismount -> teleport player and boat -> re-seat (AcidIsland /ai pattern) Entity vehicle = player.getVehicle(); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index fc42d31..1a20051 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -412,6 +412,10 @@ illegal-trade: FRONTIER: 2 LAWLESS: 1 ANARCHIC: 0 + # How far off a customs patrol surfaces, in blocks. Must clear a guardian's + # 15-block laser by a margin or the chase is an ambush - at 22 they were firing + # before the warning had finished printing. A decision window needs a decision. + patrol-distance: 45.0 # How close a patrol unit must get to make the arrest, in blocks. caught-radius: 4.0 # How far beyond the protection range a smuggler must get to shake the chase, and diff --git a/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java b/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java index 0791a3e..e2e0ab7 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java @@ -71,6 +71,37 @@ void testTheArrivalRingCanLandOnTheQuay() { assertEquals(0, onLand, "Natural land now reaches the arrival ring too - widen the search"); } + @Test + void testArrivalsAreNeverPushedInward() { + // A ragged coast can reach the arrival ring, and 4% of bearings need + // correcting. Correcting sideways or inward would drop the sailor in a + // bay or on a beach - which is what "I warped really close to the + // island" looked like. Outward is the only direction that helps. + GalaxyEngine engine = engine(); + for (IslandSpec island : engine.islandsNear(0, 0, 12_000)) { + for (int deg = 0; deg < 360; deg += 15) { + double rad = Math.toRadians(deg); + int ax = island.centerX() + (int) Math.round(Math.cos(rad) * ARRIVAL_DISTANCE); + int az = island.centerZ() + (int) Math.round(Math.sin(rad) * ARRIVAL_DISTANCE); + double nominal = Math.sqrt(island.distanceSquared(ax, az)); + + // Walk the same outward steps the arrival does + double ux = (ax - (double) island.centerX()) / nominal; + double uz = (az - (double) island.centerZ()) / nominal; + Integer landed = null; + for (int out = 0; out <= 160 && landed == null; out += 4) { + int cx = island.centerX() + (int) Math.round(ux * (nominal + out)); + int cz = island.centerZ() + (int) Math.round(uz * (nominal + out)); + if (SeaArrival.isOpenSea(engine, cx, cz, SEA)) { + landed = out; + } + } + assertTrue(landed != null, "No open water outward of " + island.name() + " at " + deg); + assertTrue(landed >= 0, "Arrival moved inward toward " + island.name()); + } + } + } + @Test void testSearchOffsetsAreOrderedNearestFirst() { // The ordering is the guarantee that an arrival lands as close to its From 2e888e700ffc828417c225c6ddca1012b7bdf916 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 11:45:33 -0700 Subject: [PATCH 071/112] Fix the deploy guard, which never matched the server jar The guard added after the hot-swap crash looked for "paper-26.2.jar". The server jar is paper-26.2-87.jar, so the pattern never matched and the guard was decorative - it would have let me overwrite a live server again. Now matches java.*paper-26\.2, with a second check on latest.log having been written in the last minute. Verified by watching it refuse while the server was up. Also added the two boat base-prices to the live config; they were the only keys missing from it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- scripts/deploy.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index cd09901..10007ae 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -16,12 +16,23 @@ set -euo pipefail ADDONS="/Users/ben/Minecraft/26.2/plugins/BentoBox/addons" JAR="target/TradeWinds-0.1.0-SNAPSHOT-LOCAL.jar" -if pgrep -f "paper-26.2.jar" > /dev/null; then +# Match the server jar loosely: it is named paper-26.2-87.jar, not +# paper-26.2.jar, so an exact pattern silently never matched and the guard was +# decorative. Keep this broad. +if pgrep -f "java.*paper-26\.2" > /dev/null; then echo "REFUSING TO DEPLOY: the test server is running." echo "Stop it first - replacing the jar under a live server crashes it." exit 1 fi +# Belt and braces: a log written in the last minute means it is probably up +LOG="/Users/ben/Minecraft/26.2/logs/latest.log" +if [ -f "$LOG" ] && [ -n "$(find "$LOG" -mmin -1 2>/dev/null)" ]; then + echo "REFUSING TO DEPLOY: $LOG was written within the last minute." + echo "The server looks live. Stop it first." + exit 1 +fi + mvn -q clean package "$@" cp "$JAR" "$ADDONS/" echo "Deployed $(basename "$JAR") to $ADDONS" From dab8c14d9b6e9587650e647c415b6e28d5465f42 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 11:52:12 -0700 Subject: [PATCH 072/112] Launch customs patrols from the dock, and stop them hitting bystanders "Again instant ambush." Twice I answered that by moving the spawn further away - 22 blocks, then 45 - and twice it was still an ambush, because the distance was never the problem. The origin was: a patrol that materialises near its quarry is an ambush at any radius. Customs now launch from the pier end of the island's own quay and swim after the smuggler. That forces a second change: swimmers starting at the dock will never close on a boat, so the customs roster now includes a phantom. Without something that can pursue, "run" stops being a choice and becomes the answer every time. (Phantoms existed only in the wanted roster, never the customs one - which answers where they were.) Police also no longer hurt bystanders. Targeting is re-asserted every couple of seconds, but a mob can swing between ticks and a guardian's beam locks on before that runs, so the damage itself is now refused unless the victim is wanted or being chased. Logging as asked: the warp arrival prints coordinates and distance from the island centre; every dispatch prints the player, the pier, the launch point, and each unit's type, coordinates and distance. 211 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 17 +++++ docs/PROGRESS.md | 36 +++++++++++ .../tradewinds/crime/PoliceDispatch.java | 62 +++++++++++++++---- .../tradewinds/crime/PoliceRoster.java | 10 ++- .../tradewinds/crime/PoliceService.java | 43 +++++++++++++ .../tradewinds/travel/WarpService.java | 4 ++ .../tradewinds/crime/PoliceRosterTest.java | 4 ++ 7 files changed, 162 insertions(+), 14 deletions(-) diff --git a/TESTING.md b/TESTING.md index aef5f75..dcdc7e8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -970,3 +970,20 @@ Use `/twadmin warpfail ` then `/tw warp` to get there on demand. a bay. (About 4% of approach bearings used to land on or beside land, because a warped coastline can reach the arrival ring; arrivals now step outward along the approach, never sideways or inward.) + +## Customs patrols launch from the dock + +Watch the console - every dispatch now logs the player, the pier, the launch +point, and each unit's distance. + +- [ ] Trigger a customs scan. The patrol appears **at the island's pier**, not + beside your boat, and swims out to you. The console line shows the launch + point and how far it is from you. +- [ ] The warp arrival is logged too: coordinates and distance from the island + centre. Cross-check it against where the patrol starts. +- [ ] There is a **phantom** in the patrol - the only unit that can catch a boat. + The guardians and drowned should fall behind if you run. +- [ ] Running works: get clear of the border and the chase breaks off. +- [ ] **A second, innocent player standing nearby is never hurt by the patrol** - + not by a guardian beam, not by a trident. Try mooring next to the smuggler. +- [ ] The patrol still switches back to the smuggler if it wanders. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 629aa86..3f44de4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,42 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Patrols launch from the dock, and leave bystanders alone (2026-08-01) + +"Again instant ambush." Twice now I had answered that by moving the spawn +further from the player - 22 blocks, then 45 - and twice it was still an +ambush, because the distance was never the problem. **The origin was.** A +patrol that materialises near its quarry is an ambush at any radius. + +Customs now launch from the **pier end** of the island's own quay, which is +where a harbour's boats put out from, and swim after the smuggler. You can see +them coming and outrun them, which is what the decision window in spec section +6 is for. + +That change forced another: with the swimmers starting at the dock they will +never close on a boat, so a customs patrol now includes a **phantom**. Without +something that can genuinely pursue, "run" stops being a choice and becomes the +answer every time. (Ben also asked where the phantoms were - they only existed +in the *wanted* roster, never the customs one.) + +**Police no longer hurt bystanders.** Targeting was re-asserted every couple of +seconds, but a mob can swing between ticks and a guardian's beam locks on +before any of that runs. The damage itself is now refused unless the victim is +the one the law actually wants - wanted, or being chased right now. A patrol +that hurts whoever is moored nearby is not policing, it is weather. + +**Logging**, as asked: the warp arrival prints the player's coordinates and +distance from the island centre, and every dispatch prints the player position, +the pier position, the launch point, and each unit's type, coordinates and +distance from the player. + +**Pitfall:** a scripted replace on `PoliceRoster` silently did not match, and +the script cheerfully printed "roster ok" anyway. The test caught it, but only +because it asserted on the roster contents. Never print success from a +replacement without checking it happened. + +211 tests green. + ## Contraband in pockets, and patrols that ambush (2026-08-01) Two from the same playtest. diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java index 6fbd779..7b9f198 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java @@ -16,6 +16,7 @@ import org.bukkit.util.Vector; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.DockPlan; import world.bentobox.tradewinds.galaxy.IslandSpec; import world.bentobox.tradewinds.galaxy.SecurityBand; @@ -67,30 +68,65 @@ public List dispatch(Player player, IslandSpec island) { int count = patrolSize(island.band()); List units = new ArrayList<>(); Location from = player.getLocation(); - // Between the player and the island: the patrol comes from the port - Vector toIsland = new Vector(island.centerX() - from.getX(), 0, island.centerZ() - from.getZ()); - if (toIsland.lengthSquared() < 0.01) { - toIsland = new Vector(1, 0, 0); - } - Location spot = openWater(from.clone().add(toIsland.normalize().multiply(interceptDistance())), from, - interceptDistance() * 0.75); - if (spot == null) { - // Ashore in the market: there is no water to launch a patrol from, - // and a guardian spawned on a plaza just flops about. The caller - // turns this into a straight confiscation instead. + // Customs launch from the HARBOUR, not from thin air beside the boat. + // Spawning them near the quarry was an ambush however far out it was + // set - the fix is not a bigger radius, it is the right origin. A + // patrol that has to row out from the pier is one you can see coming + // and outrun, which is the whole point of the decision window. + Location pier = pierOf(island, from.getWorld()); + Location launch = openWater(pier, from, minimumStandoff()); + if (launch == null) { + addon.log("Customs at " + island.name() + ": no open water at the pier (" + + describe(pier) + ") - confiscating instead of chasing"); return units; } + addon.log("Customs at " + island.name() + ": player at " + describe(from) + ", pier at " + + describe(pier) + ", launching from " + describe(launch) + " (" + + (int) launch.distance(from) + " blocks from the player)"); for (PoliceUnit kind : PoliceRoster.forCustoms(count)) { - Location at = openWater(spot.clone().add(Math.random() * 6 - 3, 0, Math.random() * 6 - 3), from, - interceptDistance() * 0.75); + Location at = spawnPoint(kind, launch, from); Entity unit = at == null ? null : spawn(at, entityType(kind), player); if (unit != null) { units.add(unit); + addon.log(" " + kind + " at " + describe(at) + " - " + + (int) at.distance(from) + " blocks from the player"); + } else { + addon.log(" " + kind + " could not be placed near the pier"); } } return units; } + /** + * The end of an island's quay - where a harbour's boats put out from. + */ + private Location pierOf(IslandSpec island, org.bukkit.World world) { + DockPlan plan = addon.getGalaxyEngine(world.getSeed()).dockPlan(island); + int pierX = island.centerX() + (int) Math.round(Math.cos(plan.bearing()) * plan.dockEnd()); + int pierZ = island.centerZ() + (int) Math.round(Math.sin(plan.bearing()) * plan.dockEnd()); + return new Location(world, pierX + 0.5, addon.getSettings().getSeaHeight() - 1.0, pierZ + 0.5); + } + + /** + * Where one unit of a patrol starts: swimmers in the water off the pier, + * the phantom in the air above it. + */ + private Location spawnPoint(PoliceUnit kind, Location launch, Location player) { + Location scattered = launch.clone().add(Math.random() * 8 - 4, 0, Math.random() * 8 - 4); + if (kind == PoliceUnit.PHANTOM) { + scattered.setY(addon.getSettings().getSeaHeight() + 12 + Math.random() * 6); + return scattered; + } + return openWater(scattered, player, minimumStandoff()); + } + + /** + * Compact coordinates for the console. + */ + private static String describe(Location location) { + return location.getBlockX() + "," + location.getBlockY() + "," + location.getBlockZ(); + } + /** * Send the standing response to a wanted player: whatever the band can * field, picked for where the target is standing. diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceRoster.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceRoster.java index 266f81e..012a0f2 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceRoster.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceRoster.java @@ -67,7 +67,15 @@ public static List forWanted(SecurityBand band, int size, boolean as public static List forCustoms(int size) { List units = new ArrayList<>(); for (int i = 0; i < Math.max(0, size); i++) { - units.add(i % 3 == 0 ? PoliceUnit.GUARDIAN : PoliceUnit.DROWNED); + // A phantom is not optional. Launching from the dock puts the + // swimmers a long way behind a boat and they will never close the + // gap, so without something that can pursue, "run" stops being a + // choice and becomes the answer every time. + units.add(switch (i % 3) { + case 0 -> PoliceUnit.GUARDIAN; + case 1 -> PoliceUnit.DROWNED; + default -> PoliceUnit.PHANTOM; + }); } return units; } diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java index fd23ec7..c44681e 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceService.java @@ -203,6 +203,49 @@ public void onPvpDamage(EntityDamageByEntityEvent event) { } } + /** + * Police never touch a bystander. + *

+ * Targeting is re-asserted every couple of seconds, but a mob can swing + * between ticks and a guardian's beam locks on before any of that runs - + * so the damage itself is refused unless the victim is the one the law + * actually wants. A patrol that hurts whoever happens to be moored nearby + * is not policing, it is weather. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPoliceDamage(EntityDamageByEntityEvent event) { + if (addon.getCustomsService() == null || !(event.getEntity() instanceof Player victim)) { + return; + } + if (!isPoliceSource(event.getDamager())) { + return; + } + if (!isWantedByTheLaw(victim)) { + event.setCancelled(true); + } + } + + /** + * Whether the law has any business with this player: wanted, or being + * chased by customs right now. + */ + private boolean isWantedByTheLaw(Player player) { + return addon.getReputationService().standing(player.getUniqueId()).isHunted() + || addon.getCustomsService().isChased(player.getUniqueId()) + || responses.containsKey(player.getUniqueId()); + } + + /** + * Whether damage came from a police unit, directly or by projectile. + */ + private boolean isPoliceSource(Entity damager) { + if (dispatch().isPolice(damager)) { + return true; + } + return damager instanceof org.bukkit.entity.Projectile projectile + && projectile.getShooter() instanceof Entity shooter && dispatch().isPolice(shooter); + } + /** * Police phantoms do not burn at dawn. A pursuit that ends because the sun * came up is not a pursuit. diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index 4b32d48..ac8865c 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -241,6 +241,10 @@ private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int f addon.getOverWorld(), to.centerX(), to.centerZ(), arrive[0], arrive[1], addon.getSettings().getSeaHeight()); + addon.log("Warp: " + player.getName() + " arriving at " + to.name() + " (" + + target.getBlockX() + "," + target.getBlockY() + "," + target.getBlockZ() + ") - " + + (int) Math.sqrt(to.distanceSquared(target.getBlockX(), target.getBlockZ())) + + " blocks from the island centre"); // Dismount -> teleport player and boat -> re-seat (AcidIsland /ai pattern) Entity vehicle = player.getVehicle(); if (vehicle != null) { diff --git a/src/test/java/world/bentobox/tradewinds/crime/PoliceRosterTest.java b/src/test/java/world/bentobox/tradewinds/crime/PoliceRosterTest.java index e0dbcd3..f3ec736 100644 --- a/src/test/java/world/bentobox/tradewinds/crime/PoliceRosterTest.java +++ b/src/test/java/world/bentobox/tradewinds/crime/PoliceRosterTest.java @@ -76,6 +76,10 @@ void testCustomsAlwaysAnswerFromTheSea() { assertFalse(customs.contains(PoliceUnit.GOLEM)); assertTrue(customs.contains(PoliceUnit.GUARDIAN)); assertTrue(customs.contains(PoliceUnit.DROWNED)); + // And something that can actually pursue. Launching from the dock puts + // the swimmers a long way behind a boat, so without a phantom "run" + // stops being a choice and becomes the answer every time. + assertTrue(customs.contains(PoliceUnit.PHANTOM), "A customs patrol needs a pursuer: " + customs); } @Test From cfb4b44fbf7b2c2d80664a35e6e3e0dbceeea70c Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 1 Aug 2026 12:16:29 -0700 Subject: [PATCH 073/112] Make patrols reachable, fix a false escape, move arrivals to the border One paste of the new dispatch logging answered three complaints at once. "The mobs spawned but nothing happened" was not BentoBox protection. They spawned fine, 200 blocks away at the pier, and simulation-distance=10 means anything past 160 blocks never ticks - so they did not swim, chase, or do anything. Launching literally from the quay was right in spirit and wrong in practice; the launch point is now pulled along the line from pier to smuggler until it is close enough to be alive, which reads as a patrol that has already rowed most of the way out. "I was next to the dock and it said I'd reached open water" was the chase timeout calling the same escaped() as a genuine border crossing - same message, same flee flag. So a player who never ran was told they had escaped and flagged at the port for it. Timeout is now its own outcome, with nothing remembered against them. "I arrived too close" was indefensible: 130 blocks is inside the island's own 160-block terrain radius, on its underwater shelf. Arrival is now the visible border at 400, the same place the warp dialog offers itself on the way out, so arriving mirrors leaving. 211 tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 17 ++++++++ docs/PROGRESS.md | 31 ++++++++++++++ .../world/bentobox/tradewinds/Settings.java | 21 ++++++---- .../tradewinds/crime/CustomsService.java | 14 ++++++- .../tradewinds/crime/PoliceDispatch.java | 41 +++++++++++++++++-- src/main/resources/config.yml | 19 +++++---- src/main/resources/locales/en-US.yml | 1 + .../tradewinds/travel/SeaArrivalTest.java | 7 +++- 8 files changed, 131 insertions(+), 20 deletions(-) diff --git a/TESTING.md b/TESTING.md index dcdc7e8..9fab984 100644 --- a/TESTING.md +++ b/TESTING.md @@ -987,3 +987,20 @@ point, and each unit's distance. - [ ] **A second, innocent player standing nearby is never hurt by the patrol** - not by a guardian beam, not by a trident. Try mooring next to the smuggler. - [ ] The patrol still switches back to the smuggler if it wanders. + +## Warp arrival distance and patrol reachability + +- [ ] A warp now lands you at the island's **visible border** (~400 blocks from the + centre), where the warp dialog offers itself on the way out - not on the + island's underwater shelf. The nav bar and dock hologram give you the heading. +- [ ] A customs patrol launches from the pier if you are near it, otherwise from as + far along the way as the server will simulate (~80 blocks). Check the console + line: the launch point should never be more than that from you. +- [ ] **The patrol actually moves.** Anything beyond ~160 blocks (simulation + distance) never ticks at all, which is what "they spawned but nothing + happened" was. +- [ ] Let a chase run past 2 minutes without leaving island space: you are told the + patrol **gives up and turns for home** - not that you reached open water - and + you are NOT flagged at that port. +- [ ] Genuinely outrun a chase across the border: you still get the open-water + message and you ARE flagged. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3f44de4..962afdf 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,37 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Patrols that never ticked, and a false escape (2026-08-01) + +The dispatch logging earned its place immediately - one paste of console output +answered three questions at once. + +**"The mobs spawned but nothing happened."** Not BentoBox protection, which was +the natural suspect: they spawned fine, 200 blocks away at the pier, and +`simulation-distance=10` means anything past 160 blocks **never ticks**. They +did not swim, chase or do anything at all. Launching literally from the quay +was right in spirit and wrong in practice. The launch point is now pulled along +the line from pier to smuggler until it is close enough to be alive +(`patrol-distance`, now 80 - inside both the simulation distance and the +96-block entity tracking range). It reads as a patrol that has already rowed +most of the way out. + +**"I was next to the dock and it said I'd reached open water."** The 120-second +chase timeout called the same `escaped()` as a genuine border crossing - same +message, same flee flag. So a player who never ran was told they had escaped +and was flagged at the port for it. Timeout is now its own outcome: the patrol +gives up and turns for home, and nothing is remembered against you. + +**"I arrived too close - I'd expect to be near where the warp dialog pops up."** +Correct, and the number was indefensible: 130 blocks is *inside* the island's +own 160-block terrain radius, on its underwater shelf. Arrival is now 400, the +island's visible border - the same place the dialog offers itself on the way +out, so arriving mirrors leaving. (The 130 came from earlier feedback that the +paddle in was too long; the navigation bar and the dock hologram have since +solved being lost, which was the real complaint.) + +211 tests green. + ## Patrols launch from the dock, and leave bystanders alone (2026-08-01) "Again instant ambush." Twice now I had answered that by moving the spawn diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index a739c01..232505b 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -387,10 +387,14 @@ public double bountyFor(Crime crime) { @ConfigEntry(path = "travel.warp.trigger-distance") private int warpTriggerDistance = 30; - @ConfigComment("How far from the destination island's CENTER a warp arrival lands. Default 130:") - @ConfigComment("inside the default view distance, so you materialize seeing your destination.") + @ConfigComment("How far from the destination island's CENTRE a warp arrival lands.") + @ConfigComment("This wants to be at the island's visible border - the same place the warp") + @ConfigComment("dialog offers itself on the way out - so arriving is the mirror of leaving.") + @ConfigComment("130 put sailors inside the island's own 160-block terrain footprint, on its") + @ConfigComment("underwater shelf: too close to feel like a voyage, and too close for a") + @ConfigComment("customs patrol to have anywhere to come from.") @ConfigEntry(path = "travel.warp.arrival-distance") - private int warpArrivalDistance = 130; + private int warpArrivalDistance = 400; @ConfigComment("Seconds between automatic warp-dialog offers at the same island's border.") @ConfigEntry(path = "travel.warp.prompt-cooldown-seconds") @@ -731,11 +735,14 @@ private static Map defaultEncounterChance() { @ConfigEntry(path = "illegal-trade.patrol-size") private Map patrolSize = defaultPatrolSize(); - @ConfigComment("How far off a customs patrol surfaces, in blocks. Must clear a guardian's") - @ConfigComment("15-block laser by a margin or the chase is an ambush - at 22 they were firing") - @ConfigComment("before the warning had finished printing. The decision window needs a decision.") + @ConfigComment("The furthest a customs patrol may be from the smuggler when it launches. They") + @ConfigComment("set out from the island's pier, but a patrol beyond the server's simulation") + @ConfigComment("distance (10 chunks = 160 blocks by default) never ticks - it does not swim,") + @ConfigComment("does not chase, and does nothing at all - so the launch point is pulled along") + @ConfigComment("the line toward the smuggler until it is close enough to be alive. Keep it") + @ConfigComment("under the monster entity-tracking-range too, or it is invisible as well.") @ConfigEntry(path = "illegal-trade.patrol-distance") - private double patrolDistance = 45.0; + private double patrolDistance = 80.0; @ConfigComment("How close a patrol unit must get to make the arrest, in blocks.") @ConfigEntry(path = "illegal-trade.caught-radius") diff --git a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java index ed9a298..0a5c484 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java +++ b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java @@ -268,7 +268,11 @@ void tick() { return; } if (System.currentTimeMillis() - chase.started() > addon.getSettings().getChaseSeconds() * 1000L) { - escaped(player, chase); + // Timed out, not outrun. Reporting this as an escape told a + // player standing beside the dock that they had reached open + // water, and flagged them at the port for running - when they + // had done nothing of the sort. + calledOff(player, chase); end(id, chase); } }); @@ -341,6 +345,14 @@ private String format(double amount) { return addon.getPlugin().getVault().map(v -> v.format(amount)).orElse(String.valueOf(amount)); } + /** + * The patrol gave up without ever catching them - the chase simply ran out + * of time. No flee flag: nobody ran, so the port has nothing to remember. + */ + private void calledOff(Player player, Chase chase) { + User.getInstance(player).sendMessage("tradewinds.customs.called-off", "[name]", chase.island().name()); + } + /** * Ran for it and made it. The port remembers: come back soon and there is * no scan roll, the patrol simply launches. diff --git a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java index 7b9f198..cdd27bf 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java +++ b/src/main/java/world/bentobox/tradewinds/crime/PoliceDispatch.java @@ -74,10 +74,10 @@ public List dispatch(Player player, IslandSpec island) { // patrol that has to row out from the pier is one you can see coming // and outrun, which is the whole point of the decision window. Location pier = pierOf(island, from.getWorld()); - Location launch = openWater(pier, from, minimumStandoff()); + Location launch = openWater(launchPoint(pier, from), from, minimumStandoff()); if (launch == null) { - addon.log("Customs at " + island.name() + ": no open water at the pier (" - + describe(pier) + ") - confiscating instead of chasing"); + addon.log("Customs at " + island.name() + ": no open water to launch from near " + + describe(pier) + " - confiscating instead of chasing"); return units; } addon.log("Customs at " + island.name() + ": player at " + describe(from) + ", pier at " @@ -97,6 +97,41 @@ public List dispatch(Player player, IslandSpec island) { return units; } + /** + * Where the patrol actually appears: from the pier if the smuggler is near + * it, otherwise as far along the way from the pier as the server will + * actually simulate. + *

+ * Launching literally from the quay was right in spirit and wrong in + * practice. A patrol dispatched 200 blocks away sits outside the + * simulation distance (10 chunks, 160 blocks, by default) and never ticks + * - so it does not swim, does not chase, and does nothing at all. The + * player watches four mobs spawn in the log and meets none of them. So the + * launch point is pulled along the line from pier to smuggler until it is + * close enough to be alive, which reads as a patrol that has already rowed + * most of the way out. + * + * @param pier the island's quay end + * @param player where the smuggler is + * @return the point to launch from + */ + private Location launchPoint(Location pier, Location player) { + double max = addon.getSettings().getPatrolDistance(); + double distance = pier.distance(player); + if (distance <= max) { + return pier; + } + // Along the line from the player toward the pier, at the maximum range + // that still ticks and can still be seen + Vector toward = pier.toVector().subtract(player.toVector()).setY(0); + if (toward.lengthSquared() < 0.01) { + toward = new Vector(1, 0, 0); + } + Location spot = player.clone().add(toward.normalize().multiply(max)); + spot.setY(addon.getSettings().getSeaHeight() - 1.0); + return spot; + } + /** * The end of an island's quay - where a harbour's boats put out from. */ diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1a20051..dc81aaa 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -75,9 +75,11 @@ travel: # Half-width of the offer ring around an island's visible border (the protection edge, # where 'Now leaving...' appears): boated players crossing it get the warp dialog. trigger-distance: 30 - # How far from the destination island's CENTER a warp arrival lands. Default 130: - # inside the default view distance, so you materialize seeing your destination. - arrival-distance: 130 + # How far from the destination island's CENTRE a warp arrival lands. This wants + # to be at the island's visible border - the same place the warp dialog offers + # itself on the way out - so arriving is the mirror of leaving. 130 put sailors + # inside the island's own 160-block terrain footprint, on its underwater shelf. + arrival-distance: 400 # Seconds between automatic warp-dialog offers at the same island's border. prompt-cooldown-seconds: 30 # Maximum destinations listed in the warp dialog (nearest first). @@ -412,10 +414,13 @@ illegal-trade: FRONTIER: 2 LAWLESS: 1 ANARCHIC: 0 - # How far off a customs patrol surfaces, in blocks. Must clear a guardian's - # 15-block laser by a margin or the chase is an ambush - at 22 they were firing - # before the warning had finished printing. A decision window needs a decision. - patrol-distance: 45.0 + # The furthest a customs patrol may be from the smuggler when it launches. They + # set out from the island's pier, but a patrol beyond the server's simulation + # distance (10 chunks = 160 blocks by default) never ticks - it does not swim, + # does not chase, and does nothing at all - so the launch point is pulled along + # the line toward the smuggler until it is close enough to be alive. Keep this + # under the monster entity-tracking-range too, or it is invisible as well as inert. + patrol-distance: 80.0 # How close a patrol unit must get to make the arrest, in blocks. caught-radius: 4.0 # How far beyond the protection range a smuggler must get to shake the chase, and diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index d63fe30..aa3470b 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -102,6 +102,7 @@ tradewinds: flagged: "[name] remembers you. No inspection this time - the patrol is already coming." jettisoned: "Your cargo goes over the side. The patrol turns back - nothing to find." escaped: "Open water. [name] loses you - but its harbourmaster will not forget." + called-off: "[name]'s patrol gives up the search and turns for home. You still have the cargo." caught: "Boarded. [number] items seized, [amount] in fines, and [name] has your name." band: safe: "Safe" diff --git a/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java b/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java index e2e0ab7..d3085a0 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/SeaArrivalTest.java @@ -24,7 +24,10 @@ class SeaArrivalTest { private static final long SEED = 20260729L; private static final int SEA = 70; - private static final int ARRIVAL_DISTANCE = 130; + /** The configured warp arrival distance - the island's visible border. */ + private static final int ARRIVAL_DISTANCE = 400; + /** The distance that used to be used, and which the quay reaches past. */ + private static final int OLD_ARRIVAL_DISTANCE = 130; private GalaxyEngine engine() { return new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.5, 6, 5000, SEA)); @@ -51,7 +54,7 @@ void testTheArrivalRingCanLandOnTheQuay() { island.centerX() + (int) Math.round(Math.cos(rad) * 5000), island.centerZ() + (int) Math.round(Math.sin(rad) * 5000), island.type(), island.band(), island.biomeKey(), "approach"); - int[] arrive = RouteGraph.arrivalPoint(from, island, ARRIVAL_DISTANCE); + int[] arrive = RouteGraph.arrivalPoint(from, island, OLD_ARRIVAL_DISTANCE); tested++; if (engine.columnPlanAt(arrive[0], arrive[1]) .filter(plan -> plan.feature() == ColumnPlan.Feature.DOCK).isPresent()) { From 4f3978d2400c8a3231380343c0b44b1cc52f8dc4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 08:17:08 -0700 Subject: [PATCH 074/112] The boat is the hold: whole-coin economy, tech levels, capture rules The playtest rework from tradewinds-hold-plan.md, plus the fixes that fell out of playing it. Cargo model rewritten. The hold is now keyed to the BOAT, not the player: one BoatHold record (material, cargo, fuel, expanders, owner, last-seen position, TTL) identified by a PDC id on both the entity and its item form. PlayerHold and DroppedBoat are gone; TWPlayerData just points at activeBoat and oldBoat. Capture takes cargo with the hull, an abandoned boat keeps its cargo, and trading needs the ship at the quay - none of which worked with a player-keyed hold. One Boat rule: 20 ranks (Bamboo Raft 2 slots to Pale Oak Chest Boat 21), quadratic prices, shops list bigger-only and gate on island tech, purchases refit the same record in place, crafting smaller is refused. Cargo is one-way - it leaves only by sale or the TNT slot - and customs stamping is removed entirely; capacity and the stock pools carry the balance now. Boats in the world: capture by boarding or pickup behind one dialog (take the hull / take only the cargo, if your own boat is alongside / leave it), owned hulls protected in non-anarchic island space, owner-or-UNOWNED name plates, death leaves the boat afloat, lava alone destroys it, and losing a boat is not abandoning one. Islands gained tech levels 1-7 (seeded, type-correlated) which gate shops, tilt prices raw-vs-finished, and furnish the plaza up to an enchanting table. Every generatable overworld land biome can now appear, surfaces follow the biome, and islets carry biome-appropriate vanilla structures with their jigsaw scaffolding scrubbed. Money is whole coins: values scaled x10 and unit prices rounded directionally - buy ceil, sell floor - so the spread can never close, and every amount is rendered by the server economy's own formatter rather than a hardcoded two decimals. 250 tests. TESTING.md reorganised by risk; the old per-stage list is kept in docs/TESTING-archive.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- # Boat mechanics.md | 127 ++ CLAUDE.md | 16 +- TESTING.md | 1259 ++++------------ TRADEWINDS_SPEC.md | 60 +- docs/PROGRESS.md | 481 ++++++ docs/TESTING-archive.md | 1301 +++++++++++++++++ .../world/bentobox/tradewinds/Settings.java | 233 ++- .../world/bentobox/tradewinds/TradeWinds.java | 67 +- .../commands/AdminIslandsCommand.java | 1 + .../tradewinds/commands/TWChartCommand.java | 51 +- .../tradewinds/commands/TWFineCommand.java | 5 +- .../tradewinds/commands/TWRestartCommand.java | 7 + .../tradewinds/crime/BountyBoard.java | 4 +- .../bentobox/tradewinds/crime/Crime.java | 10 +- .../tradewinds/crime/CustomsService.java | 8 +- .../tradewinds/dataobjects/BoatHold.java | 181 +++ .../tradewinds/dataobjects/HoldManager.java | 226 +++ .../dataobjects/PlayerDataManager.java | 16 + .../tradewinds/dataobjects/TWPlayerData.java | 36 +- .../tradewinds/economy/MarketService.java | 369 ++--- .../bentobox/tradewinds/economy/Money.java | 35 + .../tradewinds/economy/PriceModel.java | 50 +- .../tradewinds/economy/TradeCategory.java | 16 + .../tradewinds/economy/TradeDialog.java | 150 +- .../encounters/EncounterListener.java | 5 +- .../tradewinds/galaxy/GalaxyEngine.java | 169 ++- .../tradewinds/galaxy/IslandSpec.java | 14 +- .../tradewinds/galaxy/IsletStructures.java | 181 +++ .../tradewinds/galaxy/SurfaceKind.java | 20 +- .../generator/ChunkGeneratorWorld.java | 2 +- .../tradewinds/generator/IslandDecorator.java | 37 + .../tradewinds/generator/IslandPalette.java | 54 +- .../tradewinds/generator/IsletDecorator.java | 134 ++ .../listeners/SpawnRespawnListener.java | 61 + .../tradewinds/tasks/BorderCurtainTask.java | 142 ++ .../tradewinds/tasks/FuelWarningTask.java | 6 + .../tradewinds/tasks/NavigationBarTask.java | 1 + .../tradewinds/travel/BoatCraftListener.java | 116 ++ .../tradewinds/travel/BoatListener.java | 616 ++++++++ .../tradewinds/travel/BoatPickupListener.java | 22 +- .../bentobox/tradewinds/travel/BoatRanks.java | 109 ++ .../tradewinds/travel/BoatService.java | 316 ++++ .../travel/BorderPromptListener.java | 13 + .../tradewinds/travel/ChartHolograms.java | 118 +- .../tradewinds/travel/ExpanderListener.java | 108 -- .../tradewinds/travel/FuelService.java | 131 +- .../bentobox/tradewinds/travel/HoldGui.java | 597 ++++++++ .../tradewinds/travel/HoldService.java | 649 ++++---- .../tradewinds/travel/StarterKit.java | 103 +- .../tradewinds/travel/WarpService.java | 83 +- src/main/resources/config.yml | 178 ++- src/main/resources/locales/en-US.yml | 114 +- .../bentobox/tradewinds/ResourceYamlTest.java | 87 ++ .../world/bentobox/tradewinds/TestHolds.java | 91 ++ .../dataobjects/TestHoldManager.java | 50 + .../tradewinds/economy/CharityTest.java | 116 +- .../tradewinds/economy/CustomsStampTest.java | 74 - .../tradewinds/economy/PriceEngineTest.java | 10 +- .../tradewinds/economy/PriceModelTest.java | 47 +- .../tradewinds/economy/SaleCatalogTest.java | 25 +- .../tradewinds/galaxy/GalaxyEngineTest.java | 158 ++ .../galaxy/IsletStructuresTest.java | 126 ++ .../generator/ChunkGeneratorWorldTest.java | 20 +- .../generator/IslandDecoratorTest.java | 25 + .../listeners/SpawnRespawnListenerTest.java | 66 + .../tasks/BorderCurtainTaskTest.java | 37 + .../tradewinds/travel/BoatOwnershipTest.java | 257 ++++ .../travel/BoatPickupListenerTest.java | 13 +- .../travel/BorderPromptListenerTest.java | 12 +- .../travel/ChartNavigationTest.java | 6 +- .../tradewinds/travel/FuelServiceTest.java | 92 +- .../tradewinds/travel/HoldServiceTest.java | 267 ++-- .../tradewinds/travel/WarpSelectionTest.java | 25 + tradewinds-design-decisions.md | 1 + tradewinds-hold-plan.md | 185 +++ tradewinds-overview.md | 8 +- 76 files changed, 8255 insertions(+), 2351 deletions(-) create mode 100644 # Boat mechanics.md create mode 100644 docs/TESTING-archive.md create mode 100644 src/main/java/world/bentobox/tradewinds/dataobjects/BoatHold.java create mode 100644 src/main/java/world/bentobox/tradewinds/dataobjects/HoldManager.java create mode 100644 src/main/java/world/bentobox/tradewinds/economy/Money.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/IsletStructures.java create mode 100644 src/main/java/world/bentobox/tradewinds/generator/IsletDecorator.java create mode 100644 src/main/java/world/bentobox/tradewinds/tasks/BorderCurtainTask.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/BoatListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/BoatRanks.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/BoatService.java delete mode 100644 src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/HoldGui.java create mode 100644 src/test/java/world/bentobox/tradewinds/ResourceYamlTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/TestHolds.java create mode 100644 src/test/java/world/bentobox/tradewinds/dataobjects/TestHoldManager.java delete mode 100644 src/test/java/world/bentobox/tradewinds/economy/CustomsStampTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/galaxy/IsletStructuresTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/tasks/BorderCurtainTaskTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/BoatOwnershipTest.java create mode 100644 tradewinds-hold-plan.md diff --git a/# Boat mechanics.md b/# Boat mechanics.md new file mode 100644 index 0000000..b77f955 --- /dev/null +++ b/# Boat mechanics.md @@ -0,0 +1,127 @@ +# Boat mechanics + +## One Boat Rule: + +Players only have one boat at a time or no boat. + +## Boat Inventory GUI +The inventory of a boat is accessible by sitting in the boat and opening inventory, whether it has a chest on it or not, or sneaking up to the boat and right clicking on it, or by having the boat in your inventory and right clicking on it. + +The boat inventory is its own GUI panel (uses the BentoBox Panel API) with up to 21 slots available for cargo, varies by the size of the boat, a TNT icon which is used to destroy items by dragging and dropping them to it, and a set of fuel slots. + +XTXXXXXXX +XCCCCCCCX +XCCCCCCCX +XCCCCCCCX +XXXXXXXXX +XFFFFFFFX + +T = TNT, with lore saying Drag cargo here to destroy them. +C = Cargo slot +F = Fuel slot - pale red glass panel if not occupied with lore saying "Fuel Slot" +X = Light blue glass panel border, no lore. + +If the boat only has 2 cargo slots, then only two are blank. The rest of the slots are X. + +Fuel can be dragged and dropped into the GUI from the player's inventory. If it is dropped anywhere, it will take up a Fuel slot, and stack, if possible. Fuel can be taken out of the GUI or moved within the gui. Fuel will not be destoyed if dragged to the TNT. Only cargo. + +## Boat Inventory Management +In general, items are stored in optimal stacks and not spread out, so the number of slots is always optimized. + +### Removing items + +You can destory (jetison) items. This removes them from the game and they are not thrown out. Picking up the items and dropping them on the TNT item will destroy them. Trying to transfer items out of the inventory or throw them does not work. + +### Adding items + +It is possible to add any item you want to the hold except for pouches or shulker boxes. Howeverm it;s a one-way transfer. If you cannot sell it, it will just take up room, and you'll need to destroy it. Items that hold other items are not allowed because they expand the hold size. + + +## Scenarios + +### Upgrading boat (upgrading inventory size) + +There are three ways you can obtain a boat: + +1. Buy a new boat from the shop +2. Craft a boat +3. Pick up a boat item + +#### Shops + +You can only buy boats that are bigger than what you have. They cost more based on how many slots of inventory they provide. So an Mangrove Boat with 14 slots is 7x the cost of a Bamboo Raft with 2 slots. When you buy it, it replaces any boat in your inventory, or gives you a boat if there isn't one in your inventory. Remember, it's actually your personal inventory size that its changing. As such when you buy a bigger boat, all the inventory in the previous boat is now in that inventory (it actually does not "move", all that has happened is that your inventory slot size has increased). + +Examples: +1. Player has no boat. He buys a Birch Boat. He now has 6 slots that are empty. +2. Player has a Pale Oak Boat with 18 slots in his inventory. They are full of granite. He upgrades to a Cherry Chest Boat, which as 20. When he opens the Cherry Chest Boat's inventory he will see 18 slots full of granite, and 2 empty slots. The Pale Oak Boat is removed from his inventory and replaced. +3. Player has a Birch Boat. They cannot buy anything smaller at the shop. + +# Crafting a boat + +This is the same as buying a boat, except the player has done it by themselves. If the boat crafted is smaller than the one they have, then it cannot be crafted. Players can take their current boat, and add a chest to it at any time, if they can get a chest or make a chest. + +### Dropping and picking up boats + +Boats are items and can be dropped. This might be because a player throws it, or dies somehow, or the boat is broken somehow, see edge cases. When the boat is an item, the inventory that was "in" the boat goes with the item. It is now freely available for a player to pick it up. The rules are as follows, and are similar to the shop, but different because the boat item may have items in its inventory: + +1. Boat items have a time-to-live. If they are not picked up within a period of time, they are auto removed from the game. See edge case section for how to handle server restarts. +2. If the boat item is a higher rank (bigger) than the boat the player has, then it immediately replaces the player's current boat and the player's boat inventory size becomes bigger as result. +3. If the boat is smaller, they keep their current boat. +4. Inventory from the captured boat is transfered to the player's boat inventory. +5. The game will try and move in the most valuable items from the salvaged boat first. The player's current inventory remains. +6. If the player's inventory is full, the player is told that not all the items could be transfered and transfer stops. +7. Items that cannot be transfered stay in the item boat item to be picked up by someone else, or by the player if they jetison (destroy) items in their boat's inventory to make space, or go and trade items to make room and then return. +8. If the item boat's TTL expires, it is removed from the game along with any items it had in it. + +#### Examples + +1. Player finds a boat item. The player has a Jungle Boat (8 slots, with 4 of them filled) and the item is a Bamboo Raft (2 slots with items in one of the slots). The player picks up the bamboo raft item and all the items from it transfer to the Jungle Boat and the bamboo raft is removed from the game. +2. Player finds a boat item. The player has a Jungle Boat (8 slots, with all 8 of them filled) and the item is a Bamboo Raft (2 slots with items in one of the slots). The payer cannot pick up the bamboo raft item because their slots are full. +3. Player finds a boat item. The player has a Jungle Boat (8 slots, with 7 of them filled) and the item is a Bamboo Raft (2 slots full with items in both of the slots). The player receives the contents of one of the slots, and the bamboo raft remains with one slot of items in it. The player cannot pick up any more. +4. Player fills up their Oak Boat with 3 stacks of raw iron by buying it at the plaza. They throw it to the ground in the plaza, so they now have no boat. Another player can pick it up. This enables players to transfer bought items between each other. Not an exploit, but allowed. + + +#### Edge cases +1. If the item is subsequently destroyed somehow, then the inventory is lost, e.g., the item entitiy is removed by another plugin. +2. Broken boat e.g., by a trident, hitting something, etc.. Boats must not be destoyed except by being thrown in to lava. If a drowned throws a trident and hits the boat, it should just become an item. +3. If a mob picks up the boat, e.g., zombie (not sure if this is possible), then it'd be great if the inventory can be kept if the zombie is killed, but I'm not sure if this will be possible. +4. Boat items have a time to live and this should survive server crashes or restarts. I recommend the time to remove the item is stored in the database as a future timestamp. A period check is done and if any item is overdue, then it is removed from the game by UUID reference. +5. When a player dies, their boat *must* always be a dropped item. It should never be destroyed, unless they fall in lava. +6. If a player is in a situation where there is too much inventory for their hold size, then they have to pick what to keep. + + + +# Multiple boats + +Starting point: +Player has a chest boat with some inventory in it. + +Scenarios: +1. It is left somewhere as an entity - e.g., moored by the dock +2. It is in the player's inventory +3. It has become an item, and is floating in the ocean, or on the ground. + +What happens in these situations for all the above scenarios? +1. Player picks up or is given a bamboo raft boat item. The bamboo raft has no inventory in it. +2. Player picks up or is given a bamboo raft boat item. The bamboo raft has some inventory in it. + + +a) yes, you pick up the goods from the smaller hull, if there any any, otherwise, the hull just stays on the ground (and is ultimately removed from the game by the TTL). + +For the others, I think we need some rules about boat proximity to the island and trading. i.e., if your boat is not in the island space (this is a BentoBox API check) then you cannot trade at the island. You must have a boat somewhere within the island space. + +Next, what happens if you abandon a boat, that is you leave the boat unattended? + +1. Running /tw chart will show you where your active BOAT is, similarly to the DOCK option. This works anywhere in the world. +2. If you leave it unattended in any island space, it is protected from other players jumping into it or it being broken, even by mobs. This works for any island except anarchy islands. +3. If you leave a boat unattended outside of any island space, then it is fair game for capture. It is still technically yours but cannot be used for any trading because it is not in an island space. If you get into another boat, then it is no longer your main boat, and the /tw chart will show OLD BOAT until either the boat is taken by another player, or destroyed somehow. This is how players can recover their boat if they die. For example, they leave their boat at a dock, get killed by mobs on the island, go back to spawn, get a bamboo raft, and row back to the old boat, and then get into it. +4. If another player jumps into an unattended unprotected boat, then they acquire the boat and everything in it. Their current boat, if it exists, shows on the chart as OLD BOAT. +5. Being a passenger in a boat does not take over a boat. + +Special situation: logging off while in your boat, or when the boat is moored somewhere. + +1. If your boat is in a protected area, then it is protected while you are logged out. Nothing to worry about. +2. If the boat is not in a protected area or the island is anarchic, your boat stays in the world, but someone could take it. If that happens, then when the player logs in they should get a chat message telling them that it was taken/stolen. If they have no boat and are dropped into water as a result, give them a bamboo raft to enable them to paddle somewhere. + +If players want to avoid this, they can will have to jump out of the boat, break it, and store it in their inventory before logging out if they want it to be protected. + diff --git a/CLAUDE.md b/CLAUDE.md index 8ec52de..52edd08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,14 +26,18 @@ procedurally generated ocean of NPC trading islands — buy low, sell high, smuggle, hunt bounties, turn pirate. Requirements live in `TRADEWINDS_SPEC.md` (the authoritative spec; read it first). Design rationale is in `tradewinds-design-decisions.md`, the stage plan in `tradewinds-dev-plan.md`. -`docs/PROGRESS.md` records what is done and pitfalls hit; `TESTING.md` holds -the manual in-game test checklist per stage. Update both as features land. +`docs/PROGRESS.md` records what is done and pitfalls hit; `TESTING.md` is the +manual test plan, ordered by risk (Tier 1 smoke first) - add new checks to the +right tier, not to the end; `docs/TESTING-archive.md` is the old per-stage list, +history only. Update PROGRESS and TESTING as features land. Load-bearing design rules (from the spec — breaking one is a bug): -trading transacts only against bundles/boat hold; everything downstream of the -galaxy seed is a pure function of (seed, position) with **no Bukkit imports** -(package `world.bentobox.tradewinds.galaxy`), unit-tested headlessly; no End -world ever; interstice re-engage is always free; police mobs never drop loot. +trading transacts only against the **virtual hold** (DB-backed, sized by the +player's ONE boat — see `tradewinds-hold-plan.md`, normative); cargo leaves the +hold only by sale or destruction; everything downstream of the galaxy seed is a +pure function of (seed, position) with **no Bukkit imports** (package +`world.bentobox.tradewinds.galaxy`), unit-tested headlessly; no End world ever; +interstice re-engage is always free; police mobs never drop loot. ## Environment (verified — do not "upgrade" blindly) diff --git a/TESTING.md b/TESTING.md index 9fab984..72c839b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,1006 +1,253 @@ -# TradeWinds — Manual In-Game Test Checklists - -Per-stage manual verification on the test server (`/Users/ben/Minecraft/26.2`). -Deploy: `cp target/TradeWinds-*-LOCAL.jar /Users/ben/Minecraft/26.2/plugins/BentoBox/addons/` -then restart the server. Automated coverage lives in `src/test`; this file is for -what only a live server can prove. - -## Stage 0 — Addon scaffold - -- [x] Server starts with no errors/warnings from TradeWinds in the console. -- [x] `bbox version` lists TradeWinds alongside the other gamemodes (AcidIsland, AOneBlock, Gusher…), state ENABLED. -- [x] Worlds `tradewinds_world` and `tradewinds_world_nether` exist (`mv list` / console log). -- [x] `/tw` (and `/tradewinds`) teleports the player to spawn in open ocean — water to the horizon, no land, no vanilla continents. - - ~~Fail - Running `/tw` reports the error "There is no spawn in this gamemode" and nothing happens.~~ - - ~~Running `/tw create` makes an island for the player with a bedrock.~~ - - FIXED (retest): `/tw spawn` is now a TradeWinds command that teleports straight to the - world spawn on the sea surface (no spawn island needed); `create`/`reset` are removed — - player islands are purchase-only (Stage 7). Also retest: `/tw create` must now be - an unknown command. Delete the stray bedrock island from the earlier test with - `/twadmin delete `. -- [x] Ocean floor exists (dive down: sand/sandstone floor roughly y25–y50, bedrock at bottom, no caves by default). -- [x] Above-water world is air up to build height (no floating junk). -- [x] The interstice is inaccessible: nether portals in `tradewinds_world` do not activate/link (build one and light it), and no command teleports there. - - ~~FAIL: I built a portal and was able to port there.~~ - - FIXED (retest): a portal listener now cancels portal creation AND portal teleports in - both TradeWinds worlds (the config flag only stopped BentoBox's own linking, not - Multiverse's). Retest: lighting an obsidian frame should do nothing at all; the - portal built during the earlier test should also no longer teleport. -- [x] Interstice world (teleport there as admin, e.g. `mv tp`): water sea over basalt/soul-sand floor, nether ambience. -- [x] No `tradewinds_world_the_end` world is created. -- [x] `/twadmin` responds (admin help). -- [x] `addons/TradeWinds/config.yml` generated with all Stage 0 sections (galaxy, travel, illegal-trade, world). -- [x] Restart the server: worlds reload, no duplicate-world or generator errors, chunks unchanged (fly the same area). -- [x] Other gamemodes still work (create/visit an AcidIsland island). - -## Stage 1 — Seeded galaxy - -Set `galaxy.seed` in `addons/TradeWinds/config.yml` to a known value (e.g. `20260729`) -and delete the `tradewinds_world*` folders for a clean generation, then: - -- [x] Console logs `TradeWinds galaxy seed: ` on first world access. -- [x] Use `/twadmin islands` to list the 10 nearest trading islands (works before any - terrain generates — it queries the galaxy engine), then `/twadmin tpisland 1` to - visit the nearest. The registration log line (name, type, band, coords) appears - when the island's center chunk loads, i.e. on arrival. - - ~~FAIL - no islands logged in console. Cannot TP to any islands either.~~ - - Not a generation bug: islands register lazily when their center chunk first loads, - and nothing had generated chunks 3.5k+ blocks out. The missing piece was a discovery - tool — hence the two new admin commands above. -- [x] Islands rise smoothly from the ocean: underwater shelf → beach → grassy interior; no cliffs of floating terrain, no chunk-border seams, no pop-in (land is generated, not pasted). -- [x] Each island has a single whole-island biome matching its logged type (e.g. MINING → windswept hills; FROZEN → snowy, with **ice sheets in the surrounding water ring** — ride a boat over the ice: it should be fast). -- [x] Entering an island's protection range announces its name ("Now entering "). -- [x] `bbox` island info at an island (`/twadmin info` while standing there) shows an unowned island, range 1000, protection 400. -- [x] Restart the server: the same islands are still registered (no duplicate-registration log lines), names unchanged. -- [x] Regenerate the world from scratch with the same seed (stop server, delete world folders AND `database/` TradeWinds islands): identical island positions, names, types. -- [x] Islands are never within sight of one another (min separation 2500). -- [x] PvP setting: on a LAWLESS/ANARCHIC island the island PVP flag is on; on SAFE it is off. - -## Stage 2 — Island content - -**IMPORTANT: needs freshly generated island chunks.** Docks/plazas/villagers only appear -in chunks generated by this build — visit islands you have NOT been to before -(`/twadmin islands`, pick ones beyond previous exploration), or regenerate the world. - -- [x] Every island visited has a **dock**: a straight stone-brick quay with a plank deck - one block above the water, running from the island's flank out into open water. - A boat can pull up alongside the deck end. - - ~~see screenshot at /Users/ben/Downloads/2026-07-29_16.40.38.png. The dock always has a gap between it and the land. It'd be better to have it join the land, otherwise it's not going to be used much.~~ - - FIXED (retest, needs fresh chunks): the plaza's blend ring took precedence over the - dock strip, so the ring blended down to submerged terrain on the seaward side and - cut the quay off ~10 blocks from shore. The dock strip now wins over the ring: the - deck runs unbroken from the plaza edge to the pier end (one step down from plaza - to deck). A regression test walks the full dock axis on 12 islands. -- [x] The dock's plank type varies by island type (oak/spruce/acacia/cherry/dark oak). -- [x] Inland of the dock: a flattened **market plaza** (dirt-path disc) with a bell at - the center, four lantern posts, and 2–4 market stalls (fence posts + colored wool - canopy + barrel). Stall canopy color matches the island type. -- [x] Plaza edges blend into the terrain — no sheer walls around the plaza disc. -- [x] **Villagers** (3–5) stand on the plaza, professions matching the island's economy - (FISHING → fishermen; AGRICULTURAL → farmer/butcher/shepherd; MINING → mason/toolsmith; - INDUSTRIAL → smiths; LUXURY → librarian/cleric; …). Skin variant matches the biome - (snowy islands → snow villagers, desert → desert, …). - - ~~FAIL - they are always Fishermen, or no profession.~~ - - FIXED (retest, needs fresh chunks): vanilla resets a zero-XP villager with no - claimed job site to unemployed — and the stall barrels are fisherman job sites, - so the rest converged on Fisherman. Spawned villagers now carry 1 trade XP, - which locks the assigned profession permanently. Existing villagers from the - previous build will stay broken; only newly generated islands count. -- [x] Villagers are on the plaza, NOT at the waterline (shoreline-safety rule). -- [x] **Iron golems** on the plaza: 3 on SAFE, 2 on POLICED, 1 on FRONTIER/LAWLESS, - none on ANARCHIC. -- [x] Leave (unload chunks) and return: villagers and golems are still there (persistent, - no despawn). -- [x] Restart the server and revisit: residents persist, and no duplicate set spawns. -- [x] Same seed regeneration: dock bearing, plaza position, and stall layout are identical. - -## Stage 2b — Island identity (type landmarks, dressing, configurable weights) - -Playtest feedback: islands looked the same apart from biome; INDUSTRIAL didn't read -industrial. **Needs freshly generated island chunks**, as ever. - -- [x] Each island has a **type landmark** on the inland edge of the plaza (opposite the dock): - - INDUSTRIAL: brick chimney with a smoking signal fire on top (visible from the sea!), - blast furnaces, anvil, coal/iron block piles at the foot. - - MINING: timbered shaft head, rails, spoil heap, exposed ore blocks. - - AGRICULTURAL: fenced wheat plot with irrigation channel, hay-bale stack. - - FISHING: smokehouse campfire + barrel stack, and a moored rowboat at the pier end. - - FOREST: log pile with stripped logs. - - LUXURY: chiseled-quartz fountain with potted flowers. - - FROZEN: packed-ice beacon cairn with a lantern. -- [x] **Plaza surface** varies by type: polished blackstone (INDUSTRIAL), smooth quartz - (LUXURY), cobblestone (MINING), podzol (FOREST), planks (FISHING/FROZEN), - dirt path (AGRICULTURAL). -- [x] A **type-colored banner** and lantern post fly at the seaward end of every quay — - the island's "flag" as you approach by boat. -- [x] **Workstations** beside each stall (blast furnace/grindstone/anvil on INDUSTRIAL, - lectern/brewing stand on LUXURY, composter/smoker/loom on AGRICULTURAL, …). -- [x] Villagers do NOT lose or change their professions near the workstations (locked by XP). -- [x] `galaxy.type-weights` appears in config.yml; setting a type's weight to 0 and - regenerating removes that type (needs world regen — weights are seed-shaping). - -## Stage 3 — Travel: warp, fuel, charting - -Get a chest boat, put fuel in it (coal is 8 units, a coal block 80, logs 1, -a lava bucket 100 — see `travel.fuel-values`), and: - -- [ ] **Charting**: joining fresh, `/tw chart` already lists the starter-cluster islands - (pre-charted). Rowing into a NEW island's waters (range 1000) pops "Charted !" - on the action bar, and it appears in `/tw chart` with type/band/distance. -- [ ] **Warp offer**: rowing a boat across an island's VISIBLE border (the protection - edge, ~400 from center — the moment "Now leaving " appears) pops the warp - dialog automatically (once per 30s per island). `/tw warp` opens it anywhere - inside an island's waters while boated. - - ~~Playtest: no offer when rowing out past the leaving message~~ FIXED: the - trigger sat at the far range edge (~1000), 550 blocks of empty ocean later. - Now it fires right at the visible border. (Fuel never gates the offer — - unaffordable routes show greyed out.) -- [ ] The dialog lists **charted islands only** (uncharted never appear), nearest first, - with fuel cost per destination; the body shows fuel aboard. Unaffordable entries - are dark grey and clicking them just says "not enough fuel". -- [ ] **Warp**: clicking an affordable destination consumes fuel from the chest boat / - bundles (check the inventory after), plays portal particles/sound, dismounts, - teleports player AND boat, re-seats the player in the boat, applies ~8s nausea + - 3s blindness + 1 heart damage, and lands **~130 blocks from the destination's - center on the side facing the origin island** — the island is right in front of - you at default view distance (`travel.warp.arrival-distance`). - - Playtest: 350 was too far — a boring paddle, and easy to get lost even with - the HUD. Retuned to 130 per test. -- [ ] Fuel in the player's pockets (not in chest boat or bundle) does NOT count and is - never consumed — hold-only is the spec's core rule. -- [ ] Lava bucket burns to an empty bucket (bucket stays aboard). -- [ ] Fuel costs match distance × 0.01 rounded up (check two islands at a known distance - via `/twadmin islands`); add an entry under `travel.warp.edge-overrides` - (e.g. `"0,0>0,1": 1`), reload, and see that edge cost change. -- [ ] Warp back: the full row-out → warp → warp-back loop works, including re-seating. -- [ ] Chart persists across relog and server restart. -- [ ] A player without a boat: `/tw warp` refuses ("aboard a boat"); the border prompt - does not fire while swimming. - -## Stage 3b — Resident protection, band flags, navigation bar, starter kit - -Playtest feedback round: traders died to night mobs and scattered; no way to find the -dock after warping; spawn dropped you in open water with nothing. - -**Resident protection (works on EXISTING islands too — no fresh chunks needed for -the listener/tether; respawn accounting also applies everywhere):** -- [ ] Stand on a FRONTIER+ island at night: hostile mobs spawn but never chase or hit - villagers/golems; a zombie walks straight past the traders. -- [ ] On SAFE/POLICED islands: no hostile mobs spawn inside the protection range at - all (outside the 400 radius and in open ocean they still do). -- [ ] Hit a villager to make it flee: within ~30s the tether teleports it back to - the plaza. -- [ ] Kill a villager (on a non-SAFE island — on SAFE the hit is blocked outright by - the HURT_VILLAGERS flag): after ~10 minutes with the plaza loaded, a replacement - spawns at the plaza with the right profession (console logs the respawn). -- [ ] `/tw settings` while standing on a trading island: any player can VIEW the flags; - toggling is refused (unowned island, no rank). `/twadmin settings` edits work. -- [ ] Change a `bands.*` config value, `/twadmin reload` (or restart), `/twadmin reflag`: - existing islands pick up the new flags. - -**Navigation boss bar:** -- [ ] Entering any island's waters shows a boss bar: island name | Clean | Dock m. -- [ ] The distance falls as you boat toward the dock and the bar fills; it updates - about once a second. -- [ ] Bar color tracks the band: blue SAFE, green POLICED, yellow FRONTIER, red - LAWLESS, purple ANARCHIC. -- [ ] Leaving island waters (or the world) removes the bar. `hud.navigation-bossbar: - false` disables it entirely. - -**Creeper griefing (playtest feedback):** -- [ ] Lure a creeper next to a market stall / landmark and let it explode: **no blocks - break** anywhere in the world, but the blast still damages you (and would damage - other players). Residents take no damage (mob damage is blocked for them). -- [ ] `world.flags` in config.yml shows `CREEPER_DAMAGE: false`, `CREEPER_GRIEFING: true` - after a restart (the addon re-asserts these each enable). - -**Starter kit:** -- [ ] A brand-new player's first `/tw` arrival: gets a gold-named "Trading Bundle", - and — since spawn is water — an oak boat appears with them seated in it. - Kit message shows once, ever (relog + `/tw spawn` again: no second kit). -- [ ] The starter boat has the owner recorded (no visible check yet — Stage 4+ uses it). -- [ ] Returning to spawn later WITH a boat item in inventory: it is placed and you are - seated (item consumed). Without one: you swim. - -## Stage 4 — Economy and cargo - -Vault + an economy plugin must be running (they are on the test server). Base prices -come from the embedded price engine: the config table (`economy.base-prices`) plus -recipe derivation — e.g. HAY_BLOCK derives from 9x wheat even if unlisted, and a -crafted item made of priced parts is automatically sellable. - -- [ ] New player: starter kit now also deposits the starting balance ($250). -- [ ] **Open the market**: right-click any resident villager, or `/tw trade` anywhere - within an island's protection range. Main menu shows balance, island type/band, - and Sell / Buy / Shipwright buttons. -- [ ] **Hold-only is enforced**: with wheat in your POCKETS and none in the hold, the - sell page says there is nothing to sell. Move it to the chest boat (or a bundle, - max 3 count) and it appears. This is the single most important economy check. -- [ ] **Buy low**: at an AGRICULTURAL island, crops are cheap (produce factor). Buy 16 - wheat — money leaves Vault balance, wheat lands in the chest boat. -- [ ] **Sell high**: haul it to an island that demands CROPS (FISHING) — the sell price - there beats what you paid. A same-island buy-then-sell always loses money. -- [ ] **Margins scale with danger**: the same demanded good pays visibly more at a - FRONTIER/LAWLESS island than at a SAFE one (band demand bonus). -- [ ] **Stock drift**: sell a large amount of one category at one island — its prices - for that category drop. Buy an island out — prices rise. Restart the server: - the drift persists (TWIslandData in the database). -- [ ] **LUXURY islands** sell nothing (no Buy button) but pay handsomely for gems, - luxuries, and fish. -- [ ] **Shipwright**: buy a Cargo Expander ($5000) — a gold-named shulker box placed - into your chest boat. The next one costs $10,000; the cap (4) refuses further - purchases. Goods inside the expander count for selling and fuel. -- [ ] Expanders cannot be crafted (no shulker shells exist — no End). -- [ ] A 4th bundle in your inventory does NOT add hold space (max-bundles 3). -- [ ] Without a chest boat and bundles, buying refuses with "no room in your hold". - -### Stage 4b — trade screen UX + embedded pricing (feedback round) -- [ ] Every market screen shows "Balance: $X | Hold space: ~N items", updating after - each transaction. -- [ ] Sell and Buy pages have a "< Back" button returning to the main menu; the main - menu has "Close". -- [ ] Recipe-derived pricing works: an item NOT in the base price table but craftable - from priced parts (e.g. a HAY_BLOCK, CRAFTING_TABLE from planks) gets a sane - sell price; junk with no priceable recipe (e.g. a stray BEDROCK) is untradeable. - -### Stage 4c — Spawn islet and safe respawn (playtest fix) - -**Existing worlds**: the islet only appears in freshly generated chunks. Stop the -server and delete the four region files around the origin -(`tradewinds_world/region/r.0.0.mca`, `r.-1.0.mca`, `r.0.-1.mca`, `r.-1.-1.mca`) — -everything else (islands, database) is untouched; the area regenerates with the islet. - -- [ ] A grassy plains islet (~96 blocks across) exists at 0,0; world spawn is on top - of it. -- [ ] `/tw` teleports onto the islet (dry land). First-time players get the bundle and - a boat ITEM (spawn is land now, not water). -- [ ] Die without a bed: you respawn ON the islet — not at 0,42,0 in the seabed, and - not drowning. Repeat deaths respawn there every time. -- [ ] Die with a bed set somewhere: the bed is honored. -- [ ] Dying in the interstice (admin-teleport there) without a bed also returns you to - the islet. - -### Stage 4d — trade quantities (feedback round) -- [ ] Main menu: with an empty hold (or nothing this island pays for), there is NO - "Sell cargo" button; it appears once sellable cargo is aboard. -- [ ] Sell page: each cargo type is one ROW of three buttons — "x1", "x16", - "All - $total" — selling exactly that many (x16 with only 9 aboard sells 9). -- [ ] Buy page: each catalog good is a row of "x1 / x16 / x64" at quoted totals, - still limited by balance and hold space. -- [ ] More than 8 cargo types: the sell page shows the first 8 with a note; selling - some reveals the rest. - -## Stage 4e — Rower navigation: chart holograms + Star Chart - -- [ ] **Hologram compass**: in a boat, `/tw chart` raises floating name-tags around you, - each hanging in the true direction of a charted island (name / type, band / - distance). They start at you and glide out to a ~10 block ring. -- [ ] Islands sharing a bearing stack vertically — nearest lowest, further ones above. -- [ ] Holograms fade after ~15s (`chart.hologram-duration-seconds`); re-running the - command replaces them; only YOU see them (check with a second account). -- [ ] Ashore (not in a boat) or with `/tw chart list`: the text list as before. -- [ ] **Star Chart**: `/tw starchart` gives a map item. Holding it: dark-blue ocean, - you centered as a rotating arrow, charted islands as band-colored dots with - names. Islands beyond the map edge are pinned AT the edge with their name — - a heading hint until you get closer. -- [ ] The chart follows you as you row (you stay centered); scale via - `chart.starchart-blocks-per-pixel` (64 = ~8km across). -- [ ] Restart the server: an existing Star Chart item still renders (the view id - persists and the renderer re-attaches). - -### Stage 4f — the fuel guarantee -- [ ] Every island's Buy page includes at least one fuel: FOREST sells logs (and - charcoal is classified as wood, so forest charcoal is cheap), MINING sells coal, - and everywhere else — including LUXURY, which otherwise sells nothing — CHARCOAL - appears as the fallback line, priced like any commodity. -- [ ] With money but no fuel, you can always refuel at whatever island you are at. - -## Stage 4g — Two economies: stamps, outfitters, wild islets, restart - -**World note**: wild islets appear only in freshly generated chunks (empty cells you -have not visited). The protection flip and stamps work immediately. - -- [ ] **Customs stamp**: goods bought from any market carry a lore line "⚓ Customs - Stamped" (PDC-marked; `economy.stamp-glint: true` adds a glint). Stamped and - identical unstamped items never stack together. -- [ ] Only stamped goods appear on the Sell page. Homegrown wheat/mined ore in the - hold: not offered, and "traders only buy customs-stamped goods" if forced. -- [ ] **Exception**: unstamped SUGAR sells fine (the smallholder economy) — unless - `illegal-trade.enabled: false`. -- [ ] **Outfitter** button at every market: bread always; charcoal where the trade - catalog has no fuel; INDUSTRIAL sells iron sword/shield/armor; farms sell beds; - FISHING sells rods; MINING an iron pickaxe; LUXURY golden apples. All priced by - the engine (gear prices derive from their recipes). - - ~~BUG: buying a fishing rod was refused for "no hold space" with 8 free~~ - FIXED: outfitter stores now go to your INVENTORY, not the hold, and are NOT - stamped (they are for using, not reselling - which also stops bread arbitrage). - Unstackable gear could never enter a bundle, which was the actual bug. - Retest: buy a rod with no chest boat - it lands in your pack. - - [ ] Non-stacking gear (rod, sword, bed) offers only x1; stackables also offer x16. - - [ ] Buying an expander without a chest boat now says "buy one from the - shipwright first" instead of a vague hold message. -- [ ] **Trade sounds** (the dialog blurs and covers chat, so outcomes are audible): - every successful buy/sell/expander purchase plays a bright pling; every refusal - (cannot afford, no hold space, unstamped goods, expander cap, no chest boat) - plays a dull anvil thud. -- [ ] **Wild islets**: row between trading islands through fresh ocean — small - unnamed islands (~140 across) in varied vanilla biomes, no dock/market/name - announcement. You can break/place/farm/sleep there and in open ocean - (protection flipped outside named islands). Trading islands remain protected. -- [ ] **/tw restart**: confirmation, then balance resets to starting, fresh kit at - spawn, chart KEPT; counter decrements (3 by default); refuses at 0. - -### Stage 4h — the Shipwright (boat acquisition) -- [ ] Every market's main menu has a "Shipwright" page: Oak Boat ($20-ish), Oak Chest - Boat ($60-ish), and the Cargo Expander ladder (moved here from the main menu). -- [ ] Buying a hull delivers the (stamped) boat item to your INVENTORY — place it at - the dock and ride off. Expanders still require a chest boat (hold delivery). -- [ ] Boats are NOT valid warp fuel (they are hulls, not firewood). -- [ ] Wild islets have trees: chop, plank, craft boat + chest → chest boat, the frugal - path. Bought hulls cost more than crafted ones by design. -- [ ] Boatless + broke: wild-islet timber or /tw restart are the exits. - -### Stage 4i — command permissions -- [ ] As a NON-OP player (test account, no LuckPerms grants): /tw, spawn, chart, - starchart, restart, info, settings, language all work. -- [ ] Non-op canNOT use /tw warp or /tw trade (op-only shortcuts) — but the intended - paths still work for them: rowing to an island border offers the warp dialog, - and right-clicking a plaza trader opens the market. -- [ ] Non-op cannot use /twadmin or its subcommands. - -### Stage 4j — protected spawn island (updated: visitor allowances) -- [ ] Console logs "Registered the spawn island (protection 100)" on first enable. -- [ ] As NON-OP: breaking/placing blocks on the spawn islet is blocked; wild islets - and open ocean remain free-build. -- [ ] /twadmin info at spawn shows an unowned island named "Spawn", protection 100, - range 200 (small ranges are legal now — Stranger Realms overrides). -- [ ] Restart: BentoBox loads cleanly (no "island distance mismatch" panic), spawn - island persists as spawn. -- [ ] As NON-OP on the spawn island: placing/riding a BOAT works, hitting a monster - works, using a CRAFTING TABLE works — but block break/place is still blocked. -- [ ] No hostile mobs spawn on the spawn island; primed TNT there breaks no blocks - (flags re-asserted every enable, so the existing spawn island picks these up). -- [ ] Trading islands still register/announce correctly (arbitrary centers untouched - by the isFixIslandCenter override). - -### Stage 4k — teleport friction (anti-escape) -Set `commands.delay.time: 3` in `plugins/BentoBox/config.yml` and test as a NON-OP -(ops and `tradewinds.mod.bypassdelays` holders always bypass delays): -- [ ] `/tw spawn` says "stand still for 3 seconds"; moving cancels the teleport. -- [ ] Set `travel.warp.stand-still-seconds: 3` in the TradeWinds config: engaging a - warp says "Hold your course..."; standing still completes the jump. -- [ ] Moving during the countdown aborts the warp and salvages the fuel back into the - hold as charcoal (dropped at your feet if the hold is full). -- [ ] With `stand-still-seconds: 0` (default) warps remain instant. - -## Stage 5 — Risk at sea - -**Half one: the interstice (warp risk).** Set `travel.warp.failure-chance: 1.0` -temporarily to force it. -- [ ] Warping drops you into `tradewinds_world_nether` partway along the route, in - your boat, with 1-3 Ghasts inbound and a "the warp collapses!" message. -- [ ] The re-engage dialog appears within ~20s (and repeats): clicking it delivers - you to the ORIGINAL destination for FREE — no second fuel charge. -- [ ] Stranding is impossible: relog in the interstice (losing the pending - destination) — the dialog still offers a free jump to your nearest charted - island. -- [ ] Ghast fireballs can be batted back; the fight is survivable in a boat. -- [ ] Restore `failure-chance: 0.05` afterwards. - -**Half two: sea encounters (rowing risk).** -- [ ] Row in open water far from islands: within a few rolls (45s each) mobs appear - ~28 blocks AHEAD of you — visible, so fleeing is a real choice. -- [ ] Day vs night differ: guardians by day, trident-throwing drowned at night. -- [ ] Lawless/anarchic water adds worse things: an ELDER GUARDIAN in deep water - (flee — it is meant to be unwinnable for a lone trader), phantoms at night, - pillager PIRATE CREWS in their own boat, and the sea witch. - - ~~The zombie nautilus just swam away~~ FIXED: it is a tameable MOUNT, not a - monster (AbstractNautilus extends Tameable/Vehicle), so it was never going to - attack. Replaced with the elder guardian; a test now asserts every encounter - mob implements Enemy. - - Water mobs also used to spawn ABOVE the waterline, where they flop instead of - hunting. They now spawn 3 blocks under, phantoms 14 above, boats on the - surface. - - ~~The witch adrift did nothing and was easy to kill~~ FIXED: mobs riding a - boat cannot run their attack goals, and the target was set once at spawn. - An aggression pass now re-asserts targets every 2s, and boated crews - ABANDON SHIP as soon as they sight you (~30 blocks, wider than the spawn - distance) — a mob that cannot reposition lobs its potions over your head. - The abandoned boat stays behind: salvage it if you want. -- [ ] Safe island waters are quiet (2% base, further reduced near the dock); - encounters intensify with distance from any island. -- [ ] Killing encounter mobs sometimes drops booty (nautilus shells, tridents, - ingots...) which IS customs-stamped — check it can be sold at a market. -- [ ] Only one encounter at a time per player; mobs despawn naturally when far away. -- [ ] `encounters.enabled: false` turns the whole system off. - -## Stage 5b — Spawn is a real trading island - -**World note**: the origin now hosts a full trading island instead of the bare islet. -Stop the server, delete the four origin region files (`tradewinds_world/region/r.0.0.mca`, -`r.-1.0.mca`, `r.0.-1.mca`, `r.-1.-1.mca`) AND the old spawn island from the TradeWinds -database (or use `/twadmin delete` on it), then restart. - -- [ ] Console logs "Designated Spawn (FISHING) as the spawn island" on first enable. -- [ ] `/tw` puts you ON the market plaza of an island named **Spawn** — villagers, - stalls, landmark, the lot. Trading is available from second one. -- [ ] Walk down the dock, place your boat, and sail: the border warp prompt appears at - the island edge like any other island. The navigation boss bar shows "Spawn". -- [ ] The island is SAFE band: no hostile spawns, no PvP, villagers protected — plus - the harbor allowances (boats, workbenches, hitting monsters) for visitors. -- [ ] Non-op still cannot break blocks there; TNT breaks nothing. -- [ ] Die without a bed: respawn on the Spawn plaza. -- [ ] `galaxy.spawn-island-type` picks the economy (FISHING default, RANDOM allowed) — - needs a world regen to change. -- [ ] Admin control works as normal BentoBox: `/twadmin setname`, `/twadmin - setspawnpoint`, `/twadmin settings` on the spawn island. -- [ ] No other island is closer than 2500 blocks to spawn (nothing crowds the port). - -### Stage 5c — island flags (three bugs: load order, setFlag, port allowances) -- [ ] As NON-OP at ANY trading island (not just spawn): moor, exit and RE-BOARD your - boat after shopping; hit a hostile mob; use a crafting table; open stall gates. - Run `/twadmin reflag` once to push these onto islands registered earlier. -- [ ] After restart, the island JSON in `plugins/BentoBox/database/Island/` shows - `"spawn": true`, `"name": "Spawn"`, and flags including BOAT/CRAFTING/ - HURT_MONSTERS at 0. -- [ ] Only ONE island file exists at the origin (no duplicate created per restart). -- [ ] Non-op at spawn: **place and ride a boat**, use a crafting table, hit a monster. - (Rank flags were silently dropped before — see PROGRESS.md.) -- [ ] Non-op anywhere: drop and pick up items on trading islands. -- [ ] `/tw settings` at spawn shows BOAT/CRAFTING/HURT_MONSTERS at Visitor rank. -- [ ] An island registered by an older build (no name, no flags) is adopted on - restart: it gains its name, band flags, and — for spawn — protection 400 / - range 1000. - -### Stage 5d — the carried hold, pouches and white expanders -- [ ] The Shipwright sells **Trading Pouches** ($250) up to the 3-pouch limit, then - refuses ("cannot carry any more"). A pouch is deliberately poorer value per item - than an expander — the early rung, not the destination. -- [ ] Cargo expanders are **white** shulker boxes named "Cargo Expander" (vanilla - purple ones from earlier builds still work as cargo). -- [ ] Buy a Cargo Expander: it lands in your PACK (no chest boat needed). Right-click - it anywhere to open and fill it - that is your hold. -- [ ] Goods inside a carried expander are sellable, and count as fuel for warping. -- [ ] Hold space shown in the market reflects pouches + expanders (+ chest boat when - riding one). -- [ ] Riding a chest boat still adds its 27 slots to the hold; at a port you can - sneak-right-click your own chest boat to open it (CHEST flag now visitor rank). -- [ ] Loose items in your inventory are still NOT sellable (they must be in a pouch, - an expander, or the boat). - -### Stage 5e — respawn on the plaza (playtest fix) -- [ ] Die without a bed: you respawn ON the spawn island's market plaza, standing on - flat ground beside the stalls — not in the treetops at the island centre, and - not inside the bell. -- [ ] `/tw spawn` puts you in the same place. -- [ ] After `/twadmin setspawnpoint` somewhere else on the island, respawns and - `/tw spawn` both honour the new point. - -### Stage 5f — findable islets, openable expanders, charting range -- [ ] Row (or teleport) into open ocean far from any island: wild islets are now - within ~500-1700 blocks of anywhere — you should meet one within a short row. - They keep clear of trading islands. -- [ ] **Right-click a Cargo Expander in hand** to open it — this is the only way - (Java cannot open shulker boxes from the inventory). Put items in, close, and - they are still there when you reopen; they count as hold for trading and fuel. -- [ ] An expander cannot be placed as a block, and cannot be stowed inside another. -- [ ] Rowing within ~1200 blocks of an uncharted island charts it ("Charted !" - on the action bar) and it then appears in /tw chart, the warp dialog and the - star chart. `chart.sighting-range` tunes this. - -### Stage 5g — recovery: cheap first pouch and the harbourmaster's charity -- [ ] Trading Pouches cost a flat $50 each, up to the 3-pouch cap. (Escalating prices - were dropped: dropping a pouch before buying reset the count. The cap — not the - price — is what makes expanders worth buying.) -- [ ] **Soft-lock check**: with no pouch, no expander and under $50, the Shipwright - shows "Harbourmaster's charity": claim it for a free pouch (plus a boat if you - have none). You can immediately buy goods and trade again. -- [ ] The charity refuses if you are NOT destitute ("You'll manage"), and again - within 15 minutes of a claim ("Come back in N minutes"). -- [ ] Charity goods cannot be sold (unstamped), so repeat claims yield no money. - -### Stage 5h — the port scan (free chart at a trading island) -- [ ] Stand on any trading island and run `/tw chart` (or board a boat there): "You - copy the harbour charts: N islands added to yours." -- [ ] Those islands immediately appear in `/tw chart`, the hologram compass, the star - chart, and — the point of it — the warp dialog, so docking anywhere leaves you - with a full menu of onward routes. -- [ ] Running it again at the same port adds nothing (they are already charted). -- [ ] Opening the chart at sea (not at an island) does NOT scan — discovery still - requires making landfall. -- [ ] `chart.port-scan: 0` disables the free scan. - -### Stage 5i — varied ocean biomes -**Needs fresh chunks** (biomes are baked at generation). -- [ ] Sail a long way through open water: the sea changes through frozen, cold, - ordinary, lukewarm and warm ocean — water colour, fog and fish change with it. -- [ ] Transitions are gradual: you never find warm water directly against frozen. - (A test walks 80,000 blocks asserting the temperature never skips a step.) -- [ ] The same seed gives the same seas; a different seed gives different ones. -- [ ] `world.vary-ocean-biomes: false` returns the world to a single ocean biome. - -### Stage 5j — the sea itself: seabed, structures, islets -**Needs a fresh world** — terrain and biomes are baked at generation, so delete -`world/dimensions/minecraft/tradewinds_world` (and the nether one) before testing. -The test server's config has already been updated to the new defaults. - -**Islets — the "I couldn't find a single one" fix** -- [ ] Sail in any direction from anywhere: you meet an islet within a few hundred - blocks, not a few thousand. (Measured on the live seed: mean 544 blocks to the - nearest, worst case 1,376.) -- [ ] Islets vary — sandbars barely clearing the water through to islands 140 blocks - across, and small ones are lower as well as narrower. -- [ ] **Coastlines are not circles.** Islets and trading islands alike have bays, - headlands and lobes, and their land is hills and hollows rather than a smooth - dome with terraced contour rings. (`galaxy.coast-roughness` and - `galaxy.island-hilliness`; set both to 0 to see the old coin shape.) -- [ ] The shallow water around an island is not a perfectly circular pale ring — - the sea floor keeps rolling across the shelf. -- [ ] Islands are still whole: no island has broken up into a scatter of fragments, - and every trading island's quay still runs unbroken out into open water. -- [ ] Every islet has a sandy shoreline ring with beach biome (snowy beach in cold - seas), and grass or its own biome inland. -- [ ] Roughly 1 islet in 16 is **mushroom fields**: mycelium, mooshrooms, red and - brown mushrooms, and no hostile mobs spawning on it. - -**The sea floor** -- [ ] Dive in open water in several places: the floor is genuinely different depths — - sunlit banks around 14 blocks down, dark basins over 45. -- [ ] Find a **rift**: a narrow canyon wandering across the floor, dropping sharply - well below the surrounding seabed. -- [ ] Find a **seamount**: an underwater peak rising off a deep plain — and confirm it - never breaks the surface. Nothing but islands and islets should be land. -- [ ] The floor is patchy, not static: banks of sand, beds of gravel, clay pans, and - bare stone/tuff in the deeps — not alternating sand/sandstone every block. -- [ ] Water colour changes with depth as well as temperature: deep basins read as - deep ocean biomes (`F3` shows `deep_ocean`, `deep_cold_ocean`, ...). - -**What vanilla now puts there** (`make-structures: true`) -- [ ] **Shipwrecks** on the sea floor, and beached ones on islet shores. -- [ ] **Ocean ruins** — warm (sandstone) in warm/lukewarm water, cold (stone) in - colder water. They should match the water they are in. -- [ ] **Ocean monuments** in the deep basins, with guardians and elder guardians. - (Monuments only generate in deep ocean biomes, so this is the check that the - depth-to-biome mapping is working at all.) -- [ ] **Buried treasure** on islet beaches — a treasure map from a shipwreck or ruin - should lead somewhere real. -- [ ] **Trial chambers** in the rock under the sea floor: dig or cave down and find one. -- [ ] **No structure of any kind on a trading island** — no monument, ruin or village - through a plaza, dock or market. Fly around several islands and confirm. - (`world.keep-structures-off-islands: true`.) - -**Caves** (`make-caves: true`) -- [ ] **No voids in the sea floor.** Swim over a lot of open water: the floor is - unbroken. No dry craters, no open gashes, no ravines cut through to the water. - (Vanilla's carvers have no idea there is an ocean overhead and generated chunks - get no block updates, so nothing ever flows in to fill what they cut. The - generator seals a 5-block crust back over them in `generateCaves`, the Poseidon - fix — narrowed so the caves themselves survive.) -- [ ] Dig down through the sea floor anywhere promising: the caves are still there - underneath, and flood when you break into them. That is the point — sealed, not - filled in. -- [ ] Cave mouths in an island's flank *above* the waterline are left alone. -- [ ] Caves do not break into a market plaza or dock from below. - -**Regression** -- [ ] Trading islands still sit properly in the water, docks still run unbroken from - plaza to pier end, and the spawn island is unchanged — an island over a deep - basin must look the same as one over a shelf. - -## Stage 6a — The law: port flags and reputation - -**Flag policy changed for ALL trading islands.** Existing islands are re-flagged on -chunk load, but run `/twadmin reflag` after starting to be sure, then test as a -**non-op player** — every flag bug so far was invisible to an operator. - -### 6a-i — Port flags (the audit) -A trading island is now a public market: every protection flag is allowed at visitor -rank *except* an explicit deny list. -- [ ] **Things that must work** as a non-op visitor at any trading island: board and - leave a boat, open a chest boat and a cargo expander, use a crafting table, - anvil, furnace, grindstone, smithing table, loom, stonecutter, cartography - table and brewing stand, open doors/gates/trapdoors, press buttons, ring the - plaza bell, sleep in a bed, drop and pick up items, hit hostile mobs, fish, - throw an ender pearl. -- [ ] **Things that must NOT work**: break or place any block, use flint and steel, - prime TNT, empty a bucket, break an item frame or armour stand, edit a sign. -- [ ] **Things that must NOT work, because they would mint money outside the market** - (spec 5.0 — money enters the game only through trade margins): harvest or - trample the island's crops, open its barrels/containers/hoppers, take honey - from a hive, shear/milk/breed/kill its livestock. -- [ ] **Right-clicking a villager must NOT open a vanilla trade screen** — trading - goes through the market dialog and the hold only (principle 1). -- [ ] Hurting villagers is still refused on SAFE islands and allowed on rougher ones. -- [ ] `bands.port-denied-flags` / `port-allowed-flags` in config override both ways - (add `HARVEST` to allowed, reflag, and confirm you can now harvest). - -### 6a-ii — Reputation -- [ ] The navigation boss bar shows your standing, and it changes as you earn one. -- [ ] Punch a villager on a non-SAFE island: "The market saw that. Reputation -5." -- [ ] Kill a villager: bigger loss, and the message mentions money on your head. -- [ ] Kill an iron golem: reputation loss for killing police. -- [ ] Crossing a band boundary shows a title with your new standing. -- [ ] Commit enough crime to reach **Wanted** (−200 by default; one murder plus - change, or eight villager kills). Then **Fugitive** at −500. -- [ ] Reputation decays back toward zero by 1 point per 15 minutes of play — and - **only while online and in a TradeWinds world**. Log out for a while and - confirm your reputation is exactly where you left it. -- [ ] `/tw fine` out at sea is refused ("paid at a trading island"). At a port it - quotes a price, asks for confirmation, charges you, and returns you to Clean — - **never above it**. Check the balance actually moved. -- [ ] With nothing to answer for, `/tw fine` says so and charges nothing. -- [ ] As a Fugitive, `/tw fine` at a **SAFE** island is refused; at a rougher port it - works. (Crime pays, into danger — and buying your way back costs you the same.) -- [ ] `crime.enabled: false` stops all of it: no penalties, no messages, standing - reads Clean for everyone. - -### 6a-iii — PvP and bounties (partial; police land in 6c) -- [ ] Killing a player who is **not** a lawful target costs the killer 100 reputation - ("Murder"). -- [ ] But if the victim hit you first (within 30s), there is **no** penalty — the - anti-bait guard. Verify both ways round; a wanted player being able to farm - reputation loss off innocents by attacking them would be a weapon. -- [ ] Killing a **Wanted or Fugitive** player pays their bounty to the killer with no - penalty, and the bounty is cleared — kill them again and it pays **nothing**. - -## Stage 6b — Customs and contraband - -Contraband is **sugar** by default (`economy.unstamped-sellables`) — the one thing a -player can make and still sell, and the deliberate hole in the two-economies rule. -Grow some, or `/give` yourself sugar, and put it in your **hold** (a pouch, expander -or chest boat — loose pockets are not cargo and are not scanned). - -**Use `/twadmin customs`** — it prints the whole customs state where you stand -(contraband in your hold, your standing, this island's band, your effective scan -chance, patrol size, whether a chase is running). Scans are probabilistic and -cooldowns are invisible, so guessing from behaviour alone is a trap. - -### 6b-0 — Fixes from the first playtest -- [ ] The customs alert reads "CUSTOMS" with "Patrol dispatched from " beneath - it — **no line-feed glyph**. (A `` inside a title half is parsed into a - real newline character, which a title cannot render.) -- [ ] A chat line follows the title and stays in the log, so a blink does not lose it. -- [ ] **Log in inside a port with contraband: nothing happens.** Logging in is not an - entry. Row out and back in and the scan fires normally. -- [ ] **Warp away mid-chase, then into another island's space: the new island scans - you.** (Previously the old chase stayed live and blocked every later scan, so - customs silently stopped working for the rest of the session.) -- [ ] Standing on a plaza with contraband when a scan fires: there is no water for a - patrol to launch from, so customs simply seize the cargo and fine you, with a - message saying so — rather than spawning guardians that flop about on land. - -### 6b-0b — Fixes from the second playtest -- [ ] **Contraband actually pays.** Sell sugar at a FRONTIER/LAWLESS/ANARCHIC port: - roughly $12-14 an item, not $1. It pays more the rougher the port. - (`illegal-trade.contraband-price-multiplier`, default 8.0 — the main balance - lever, and the one price in the game not funded by a purchase elsewhere.) -- [ ] The sell page **no longer quotes a price for contraband at a port that will - refuse it** — sugar simply is not listed at SAFE/POLICED islands, instead of - being offered for a dollar and then declined at the counter. This is what made - `/twadmin customs` look like it was lying: it said "buys contraband: false" - while the dialog was showing an offer. -- [ ] **No trader stocks contraband.** Check the buy page at an AGRICULTURAL island: - no sugar. (If they sold it you could buy at the honest price and sell at the - black-market premium with no farming and no risk.) -- [ ] **A patrol never spawns on top of you.** It surfaces at least ~12 blocks away, - well beyond the 4-block arrest radius, so there is always a chase to run. - Previously the water search fell back to the player's own position: patrols - materialised alongside the boat, opened fire, and the arrest registered before - the warning had been read — "I got instantly hurt and lost the sugar". -- [ ] Note that warp arrival damage (`travel.warp.damage`, default 2.0) is separate - and intended — warping hurts. Do not confuse it with a patrol. - -### 6b-i — The scan -- [ ] Row or warp into a **SAFE** island's protection range carrying no contraband: - "Customs board you, look through the hold, and wave you on." (Set - `announce-clean-scans: false` to silence it.) -- [ ] Do it again immediately — no second scan, the per-island cooldown holds - (10 min default). This is what stops re-entry dice-rolling. -- [ ] Enter a **SAFE** island with sugar in the hold: title card "CUSTOMS — Patrol - dispatched", and drowned/guardians surface **between you and the island**. -- [ ] Warping in triggers the scan exactly like rowing in. -- [ ] Enter an **ANARCHIC** island with contraband: no scan at all, ever. -- [ ] Being **Upstanding** noticeably reduces how often you are searched; being an - Offender or worse increases it. - -### 6b-ii — The chase, and its three ways out -- [ ] **Jettison**: drop the sugar mid-chase. "Your cargo goes over the side" — the - patrol despawns, no fine, no reputation loss. The dropped items float and - **anyone else can pick them up** (this is where piracy comes from). -- [ ] **Run**: get more than 400 blocks beyond the protection range. "Open water..." - — you keep the cargo, no penalty. -- [ ] **Caught**: let a patrol unit reach you (within 4 blocks) or let one hit you. - All contraband is seized, a fine is taken, and reputation drops by 30. - Check the balance actually moved and the hold is actually empty of sugar. -- [ ] Fight the patrol instead: killing a unit costs reputation (KILL_POLICE)... -- [ ] ...and **police drop nothing at all** — no items, no XP. Kill several and - confirm. (Loot-bearing police would make a criminal record an iron farm.) -- [ ] The chase ends by itself after 2 minutes if nothing else resolves it, and the - patrol despawns. Log out mid-chase and the patrol is cleaned up too. - -### 6b-iii — The flee flag -- [ ] After escaping a chase, go back to **that same island** within 20 minutes: - "[name] remembers you... the patrol is already coming." No scan roll — the - patrol launches immediately. -- [ ] A *different* island still rolls normally. The flag is per-island. -- [ ] The flag expires after 20 minutes. - -### 6b-iv — Selling contraband -- [ ] Try to sell sugar at a **SAFE** or **POLICED** island: refused, with the trader - telling you to try a rougher port, and the dull "no" sound. -- [ ] Sell it at a **FRONTIER**, **LAWLESS** or **ANARCHIC** island: it sells, with no - customs stamp needed. This is the only way to turn farmed goods into money. -- [ ] `illegal-trade.safest-contraband-buyer: SAFE` makes every port buy it; - `ANARCHIC` makes it almost unsellable. -- [ ] `illegal-trade.enabled: false` removes all of it: no scans, no patrols, and - sugar becomes an ordinary unsellable homemade good. - -## Stage 6c — Police, wanted response, bounties - -Get yourself **Wanted** (−200: one murder, or ~8 villager kills) with -`/twadmin customs` to check your standing, then sail into a policed island's space. - -### 6c-i — The standing response -- [ ] Enter a **SAFE** or **POLICED** island's protection range while Wanted: "…has - patrols out for you", and units appear — guardians, drowned and a **phantom** - if you are afloat; **iron golems** and a phantom if you are ashore. -- [ ] The phantom is the only thing that can follow a boat. Row away and confirm it - keeps up while the swimmers fall behind. That is the point of the roster. -- [ ] Phantoms **do not burn at dawn**. Trigger a response in daylight and watch. -- [ ] A **FUGITIVE** draws a larger response than merely Wanted. -- [ ] **LAWLESS** sends one unit; **ANARCHIC** sends nobody at all. Confirm you can - sit in anarchic water as a fugitive completely unmolested — that is the whole - reason to go out there. -- [ ] Police ignore innocent bystanders: stand a second (clean) player next to the - patrol and confirm it stays on the wanted one. - -### 6c-ii — Break-off and no leaks -- [ ] Cross the border and keep going: past ~400 blocks beyond the protection range - the patrol breaks off ("The patrol turns back at the border") and the units - are **removed**, not left drifting. -- [ ] Pay your fine mid-pursuit (`/tw fine`): the patrol stands down immediately. -- [ ] Log out mid-pursuit, log back in: no orphaned police anywhere. -- [ ] Fly a long way off and come back: no accumulation of stray guardians or - phantoms. (`/twadmin customs` shows whether a chase is live.) -- [ ] Kill police: still **no drops, no XP**, whatever the unit type. - -### 6c-iii — PvP override and bounties -- [ ] A Wanted player can be attacked **even on a SAFE island** where the PvP flag is - off. A clean player in the same place still cannot be attacked. (Without this a - wanted player could moor in high security and be untouchable.) -- [ ] Killing them pays their bounty to the killer, with no reputation penalty. -- [ ] The bounty is cleared by the payout — kill them again and it pays **nothing**. -- [ ] Their name shows the bounty beside it while it is above zero, and the tag - disappears once paid out. (`crime.bounty-nameplate: false` removes it.) -- [ ] With PlaceholderAPI installed, `%tradewinds_bounty%`, `%tradewinds_standing%`, - `%tradewinds_reputation%` and `%tradewinds_wanted%` all resolve. Servers using - TAB should turn the nameplate off and use these instead. - -### 6c-iv — Fugitive trade bar -- [ ] As a **Fugitive**, the market at a SAFE or POLICED island refuses to open at - all ("We know what you are"). -- [ ] At FRONTIER and beyond it opens normally. Merely **Wanted** is not barred — - only Fugitive. (`crime.safest-fugitive-trader`.) - -## Interstice: a failed warp must not be an execution - -Playtest: a new player's first warp failed, ghasts opened fire instantly, and they -died having lost everything before they could read the dialog offering the free way -out. Set `travel.warp.failure-chance: 1.0` temporarily to test this repeatedly. - -- [ ] Fail a warp several times. Roughly **40% of the time nothing comes at all** — - dark water and silence (`interstice.ghast-chance`). -- [ ] When ghasts do come, they appear **~90+ blocks away** and are **not already - hunting you**. You can see them, and decide. (They spawn beyond their own - 64-block detection range and are deliberately not given a target.) -- [ ] For **20 seconds after arrival** nothing can target or damage you - (`interstice.grace-seconds`) — long enough to read the dialog and click - re-engage. Confirm a fireball already in the air does not kill you either. -- [ ] Re-engaging is still free, and still returns you to the original destination. -- [ ] Stay past the grace and approach them: it becomes a real fight. The grace - covers the arrival, not the visit. -- [ ] A brand new player with a starter kit can survive a failed first warp without - losing their boat and cargo. That is the actual bar here. - -## Warp arrivals must never land inside anything - -- [ ] Warp repeatedly to the same island from the same origin. You always arrive on - **open water**, never inside the quay decking. (The arrival ring is 130 blocks - from the centre; the quay reaches 136 with its deck at exactly arrival height, - so ~0.9% of bearings used to suffocate you on arrival.) -- [ ] Arrivals still land close to the intended spot - the search takes the nearest - open water, so the island should still be in view. -- [ ] `/twadmin warpfail ` rigs that player's next warp to fail. Run it - again on the same player to clear it. Check the console/chat feedback both ways. -- [ ] After a rigged failure, the flag is gone: the next warp behaves normally. -- [ ] Interstice arrivals also land on open water, not inside the interstice floor. - -## Low fuel warning, and reading the boss bar - -- [ ] Stand at a port with less fuel than the cheapest charted warp costs: a **LOW - FUEL** action bar appears and **repeats** every 8 seconds while you are there. -- [ ] A **chat line** also arrives, once per port visit, saying how much more fuel - you need. It stays in the chat log — this is the one a child will still find - after looking away. -- [ ] Open the market: the body says LOW FUEL, and the button that actually sells - fuel is relabelled **"BUY FUEL HERE"** — the Outfitter normally, or the Buy - Goods button at islands whose own catalog stocks fuel. -- [ ] Buy fuel: all three warnings stop. -- [ ] With nothing charted to warp to, there is **no** warning — being unable to - warp is not a fuel problem then. -- [ ] Out at sea (not at a port), no warning. It is only raised where it is fixable. -- [ ] `travel.fuel-warning.margin: 1.5` warns while you can still just about leave; - `enabled: false` removes all of it including the dialog highlight. - -- [ ] The navigation boss bar now reads **name | band | standing | dock distance**, - with the band coloured — blue Safe, green Policed, yellow Frontier, red - Lawless, bold dark-red ANARCHIC. Sail into each and confirm you can tell at a - glance what kind of water you are in. -- [ ] The same coloured band appears on the chart holograms and the market subtitle. - -## /tw go - the door into the ocean, and nothing more - -`/tw spawn` was a free warp back to a market once spawn became a working trading -post. But deleting it locked new players out entirely, so it is now a door with -two rules. - -- [ ] **From another world** (or on first ever join): `/tw` or `/tw go` puts you - into the ocean. A brand new player arrives at the spawn port with a starter kit. -- [ ] **A returning player arrives where they LEFT the ocean**, not at spawn. Sail - a long way out, `/mv tp world` (or `/acid`), then `/tw` - you should be back - in the same stretch of water, not at a market. -- [ ] **Already at sea**: `/tw go` refuses ("You are already at sea..."). This is the - exploit that had to close - there is no commanding your way to a market. -- [ ] Log out at sea, log back in: you are where you left off, and `/tw go` still - refuses. -- [ ] Dying still respawns you at the spawn plaza (not a cheat - you lost your cargo - to get there). -- [ ] `/tw restart` still returns a destitute player to spawn with a fresh kit, - capped by `player.max-restarts`. -- [ ] The old `/tw spawn` label still works as an alias, and does the same thing. - -## Dock marker on the hologram chart - -- [ ] Inside an island's waters, `/tw chart` (or boarding a boat) now raises a - **DOCK** hologram alongside the island names, pointing at that island's pier - with its distance. Row toward it and the number falls. -- [ ] It points at the **pier**, not the island centre - check on an island whose - dock faces away from you. -- [ ] It hangs **below** the island name markers and has a warmer background, so it - never collides with their stack. -- [ ] Out in open ocean, away from any island, there is no dock marker - just the - island names. -- [ ] With an empty chart but inside an island's waters, the dock marker still - appears on its own. (Previously an empty chart showed nothing at all.) -- [ ] The label is translatable: `tradewinds.hologram.dock`. - -## Star chart: fuel range, reachability, readable names - -- [ ] Hold the Star Chart: island names are **white** and clearly readable against - the blue ocean (they were a mid grey that all but vanished). -- [ ] A **dashed ring** is drawn around you at the limit of your fuel range. Burn or - buy fuel and it shrinks/grows on the next redraw (about a second). -- [ ] With no fuel there is no ring; with a huge amount the ring is off the chart and - is simply not drawn, rather than being clamped to the edge and lying about it. -- [ ] Islands inside the ring are the ones you can afford to warp to - cross-check - against `/tw chart list`. -- [ ] `/tw chart list` shows **Fuel aboard: N units** and marks every entry with its - fuel cost: green and plain when reachable, greyed with "(not enough fuel)" when - it is not. -- [ ] Standing at a port, the quoted cost matches the warp dialog exactly (it uses - the same route price, including any lane overrides in - `travel.warp.edge-overrides`). -- [ ] Adrift between islands the cost is an estimate from your position - close - enough to plan by. It should still fall as you approach a destination. - -## Dialogs close under attack; no warping out of a fight - -- [ ] Open any dialog (warp, market, shipwright) and take damage: the dialog - **closes**. A modal screen hides the boat, the water and whatever is shooting. -- [ ] Any damage does it, not just mobs - drown while reading a shop menu. -- [ ] With a hostile mob within 12 blocks, the warp dialog **refuses to open** and an - action bar says "You cannot warp while enemies are close." Same idea as a bed - refusing to let you sleep. -- [ ] Kill or outrun the mob and the dialog opens normally. -- [ ] Let a mob arrive **while the dialog is already open**, then click a - destination: the warp is refused, and no fuel is taken. (Checked again at the - moment of engaging, because that is when the fuel is spent.) -- [ ] With `travel.warp.stand-still-seconds` above 0, a mob arriving during the - countdown aborts the warp and **refunds** the fuel. -- [ ] **A customs chase cannot be escaped by warping** - the patrol counts as - enemies. Run, fight or jettison, as designed. -- [ ] **The interstice re-engage still works with ghasts nearby.** This is the one - exemption and it matters: it is the way out of somewhere dangerous, and gating - it would strand players. -- [ ] `travel.warp.enemy-radius: 0` disables the gate (dialogs still close on damage). - -## Star Chart is ephemeral - -- [ ] `/tw starchart` puts the chart straight **into your hand** (a free hotbar slot, - selected for you) rather than somewhere you have to hunt for. -- [ ] **Switch to another hotbar slot: the chart is gone.** Run the command again to - consult it - it is an instrument, not cargo. -- [ ] Run `/tw starchart` twice: you end up with **one** chart, not two. -- [ ] Try to drop it: it vanishes rather than bobbing in the sea. -- [ ] Die holding it: it is **not** in your death drops, and nobody can salvage one. -- [ ] Log out holding it, log back in: gone, and the command still works. -- [ ] Sanity check the point of all this: after a dozen `/tw starchart` calls there - are no stray maps in your inventory, on the ground, or in your hold. -- [ ] With a completely full hotbar it is added anywhere it fits rather than refused. - -## The interstice: lit, lidded, and ghasts you can actually see - -**Needs fresh interstice chunks** - delete `world/dimensions/minecraft/tradewinds_world_nether`. -Use `/twadmin warpfail ` then `/tw warp` to get there on demand. - -- [ ] **The ghasts are visible and close enough to identify** - about 28 blocks, in - a tight band (the spread used to add up to 30 blocks on top of the setting, - which quietly undid it: a base of 44 arrived as far out as 74). -- [ ] **They come for you when the grace runs out.** They arrive without a target so - landing is not an ambush, but after 20 seconds anything nearby acquires you. - Previously they never re-aimed at all, so they drifted and nothing happened. -- [ ] The re-engage dialog has a **"Stay a while"** exit button. Declining costs - nothing - the offer comes round again and the fuel is already spent. -- [ ] Some failures still bring nothing at all (`ghast-chance: 0.6`). -- [ ] The 20-second arrival grace still holds - nothing targets or hurts you while - you read the dialog. -- [ ] **There is a ceiling** about 48 blocks above the sea: netherrack with bedrock - on top. Fly up and confirm you cannot leave. -- [ ] **Burning braziers** - netherrack outcrops rising out of the water with fire on - top - appear across the sea, roughly one chunk in six. They light the place and - give you something to steer by. -- [ ] The fires stay lit (netherrack burns forever) and do not spread - there is - nothing out there to catch. -- [ ] Some braziers have glowstone at their base, visible from underwater. -- [ ] Overall: the interstice reads as somewhere hostile you are trapped in, rather - than an unfinished black void. - -## Contraband: pockets count, and the patrol gives you room - -- [ ] **Sugar in your normal inventory slots is scanned too**, not just the hold. - Try the obvious dodge: move the sugar from a pouch into your pockets, cross a - border, stow it again. You are still caught. (Hold-only scanning made that a - free pass.) -- [ ] Being caught seizes contraband from **both** the hold and the pockets - check - the item count in the message against everything you were carrying. -- [ ] A patrol now surfaces about **45 blocks** away, comfortably beyond a guardian's - 15-block laser, so there is a real moment to turn and run. They should not be - hitting you before the warning has finished printing. -- [ ] Warp into an island: you arrive **clear of the shore**, not on the beach or in - a bay. (About 4% of approach bearings used to land on or beside land, because a - warped coastline can reach the arrival ring; arrivals now step outward along - the approach, never sideways or inward.) - -## Customs patrols launch from the dock - -Watch the console - every dispatch now logs the player, the pier, the launch -point, and each unit's distance. - -- [ ] Trigger a customs scan. The patrol appears **at the island's pier**, not - beside your boat, and swims out to you. The console line shows the launch - point and how far it is from you. -- [ ] The warp arrival is logged too: coordinates and distance from the island - centre. Cross-check it against where the patrol starts. -- [ ] There is a **phantom** in the patrol - the only unit that can catch a boat. - The guardians and drowned should fall behind if you run. -- [ ] Running works: get clear of the border and the chase breaks off. -- [ ] **A second, innocent player standing nearby is never hurt by the patrol** - - not by a guardian beam, not by a trident. Try mooring next to the smuggler. -- [ ] The patrol still switches back to the smuggler if it wanders. - -## Warp arrival distance and patrol reachability - -- [ ] A warp now lands you at the island's **visible border** (~400 blocks from the - centre), where the warp dialog offers itself on the way out - not on the - island's underwater shelf. The nav bar and dock hologram give you the heading. -- [ ] A customs patrol launches from the pier if you are near it, otherwise from as - far along the way as the server will simulate (~80 blocks). Check the console - line: the launch point should never be more than that from you. -- [ ] **The patrol actually moves.** Anything beyond ~160 blocks (simulation - distance) never ticks at all, which is what "they spawned but nothing - happened" was. -- [ ] Let a chase run past 2 minutes without leaving island space: you are told the - patrol **gives up and turns for home** - not that you reached open water - and - you are NOT flagged at that port. -- [ ] Genuinely outrun a chase across the border: you still get the open-water - message and you ARE flagged. +# TradeWinds — Manual Test Plan + +What only a live server can prove. Automated coverage lives in `src/test` +(248 tests); this is the rest. + +**Ordered by value, not by stage.** Work top to bottom and stop when you run +out of time — everything above the line you reach is more likely to be broken, +or worse to be broken, than everything below it. Tier 1 is fifteen minutes and +catches what makes the game unplayable; Tier 6 is polish. + +The pre-2026-08-02 stage-by-stage list lives in `docs/TESTING-archive.md`. It +covers a lot of mechanics that no longer exist (pouches, customs stamps, +carried expanders, the chest boat as a hold) — read it for history, not for +coverage. + +## How to run + +```bash +./scripts/deploy.sh # builds, then installs - refuses while the server is up +``` + +The script will not overwrite a running server's jar: replacing it live +invalidates the plugin classloader and takes the server down inside chunk +generation. Stop the server first; the guard also waits for `latest.log` to go +quiet for a minute. + +**Clean slate:** after any change to the hold, boat or galaxy model, delete the +`tradewinds_world*` folders **and** the `BoatHold`, `TWPlayerData`, +`TWIslandData` database folders. Stale records from a previous model are +indistinguishable from bugs. + +Two accounts are needed for part of Tier 3. Everything else is single-player. + +--- + +# Tier 1 — Smoke test (~15 min) + +If any of these fail, stop and report: the game is unplayable and the rest is +noise. + +## Boot + +- [ ] Server starts with no TradeWinds errors or warnings. (Duplicate-key YAML + warnings are a build bug the suite should have caught — mention any.) +- [ ] `bbox version` lists TradeWinds ENABLED alongside the other gamemodes. +- [ ] `tradewinds_world` and `tradewinds_world_nether` exist; no `_the_end`. +- [ ] `addons/TradeWinds/config.yml` has the current sections: `galaxy`, + `boats`, `border`, `economy`, `travel`, `illegal-trade`. +- [ ] Other gamemodes still work (create or visit an AcidIsland island). + +## The first ten minutes of a new player + +- [ ] `/tw` puts you at the spawn port: plaza, stalls, villagers, dock. Not in + the treetops, not in the seabed. +- [ ] You get an **Oak Boat**, and the message names it, its 3 cargo slots and + the coal in its fuel tank. +- [ ] The boat item's lore reads `Cargo: 0/3 slots`, `Fuel: 64 units`, + `Right-click to open the hold`. +- [ ] Right-click the boat item: the hold opens — TNT slot, 3 open cargo slots, + the rest grey, 7 fuel slots holding the coal. +- [ ] Place the boat on water and board it: **no dialog**, no OLD BOAT, coal + still aboard. (Historically the most-broken path in the game — go slowly.) + +## The core loop + +- [ ] Open the market at the plaza and buy cargo: it lands in the hold, money + leaves, slots fill. +- [ ] Row to the island border: a **red** particle curtain, and the warp dialog + fires as you touch it — not 30 blocks early. +- [ ] Warp: you and the boat arrive facing the **dock**; hold forward and you + reach it. +- [ ] Sell the cargo at the new island at a different price than you paid. +- [ ] `/tw chart` raises holograms: island names and a DOCK marker, and no BOAT + marker while you are sitting in the boat. + +--- + +# Tier 2 — The hold (~20 min) + +The hold is virtual and one-way. Any route that gets cargo out unintentionally +is a duplication bug, which is the worst kind. + +- [ ] Click items in your pack to deposit: fuel to the fuel row, everything + else to cargo. +- [ ] **Refused:** bundles, shulker boxes, chests, barrels, and any boat. +- [ ] **Nothing extracts cargo.** In the hold GUI try shift-click, number keys, + double-click-gather, drag-split, cursor swap, drop key. All must fail. +- [ ] Fuel *does* come back out on click, and fuel-valued **cargo** (coal + bought at market) moves to the fuel row when clicked. +- [ ] Select a cargo stack, click the TNT: destroyed, not dropped. +- [ ] Cargo consolidates: 40 + 30 wheat is 2 slots, not 2 spread stacks. +- [ ] Fill the hold, then buy more: refused with a message and no money taken. +- [ ] Deposit fuel, close the GUI, hover the boat item: the lore has updated. +- [ ] In a **chest** boat, press the inventory key while riding: the hold + opens. (A plain boat cannot do this — Minecraft never tells the server.) + +--- + +# Tier 3 — Boats: ownership, capture, loss (~30 min, two players) + +The newest and most bug-prone system. Every check here has had a real bug +behind it. + +## One boat + +- [ ] The shipwright lists only boats **bigger** than yours, and only up to the + island's tech level. +- [ ] With **no** boat, buy a Bamboo Raft: item in your pack, money gone once, + hold opens with 2 slots. +- [ ] With a boat **at the port**, buy a bigger hull: same hold, same cargo, + more slots — and the hull you ride or carry visibly becomes the new type. +- [ ] With your boat **elsewhere**, the yard refuses the refit and says to + bring the ship in. +- [ ] Craft a bigger boat **by clicking the result** (not shift-clicking): the + item has lore and right-click opens the hold. Repeat with shift-click. + Crafting a smaller one is refused. + +## Finding a hull + +- [ ] Walk over an unowned boat item while you own a boat: one dialog (not one + per tick) offering **Take the boat**, **Take only the cargo**, **Leave it**. +- [ ] "Take only the cargo" appears only when your own boat is within 200 blocks + and the hull carries something; with your boat on another island the + option is gone and the dialog says why. +- [ ] **Nothing teleports:** with your boat far away, no route moves cargo to it. +- [ ] Take a hull while carrying your own: you end with **one** boat item, all + the cargo in it, and the old hull on the ground. Never two in the pack. +- [ ] Immediately after that swap, standing on the shed hull does NOT re-open + the dialog; after ~5 seconds it does. Dropping and re-picking your OWN + boat works at once, even inside that window. +- [ ] Right-clicking a boat item that is not your ship does nothing. +- [ ] Boarding an unattended hull offers the same three answers. + +## Capture and loss (two players) + +- [ ] Player 2 online when player 1 takes their boat: told immediately. +- [ ] Player 2 then has no boat: no cargo slots at market, **no** low-fuel + warnings, **no** OLD BOAT on the chart (it was taken, not abandoned). +- [ ] Player 2 can still use the **outfitter and shipwright** — buying a hull is + the only way off the island. Only Sell/Buy cargo are withheld. +- [ ] Restart the server: no repeat "taken while you were away", and player 1 + still owns the boat. +- [ ] Player 2 offline when it happens: told once, at next login. + +## Protection and abandonment + +- [ ] Boat plates read the owner's name, or UNOWNED; hidden while ridden. +- [ ] Another player's unattended boat in protected island space cannot be + boarded or broken — mobs included. On an ANARCHIC island it can. +- [ ] Unowned hulls are takeable anywhere, even at a safe dock. +- [ ] Chart markers point only at boats you must travel to: never one you carry, + never one under you, never one within ~32 blocks. +- [ ] `/tw chart list` names both boats with coordinates and distance, or says + plainly that you have none. + +## Death and recovery + +- [ ] Die: the boat stays floating where it was and is still yours. +- [ ] Respawn with the ship far away: you are lent a Bamboo Raft. Board it → + confirmation → your real boat becomes OLD BOAT. +- [ ] Row back and board it: yours again, and the marker clears. +- [ ] Lava is the only thing that destroys a boat and its cargo. +- [ ] Log out in open water: another player can take the boat. Log out inside + protected space: safe. + +--- + +# Tier 4 — Economy depth (~20 min) + +- [ ] Every price on every chart is a **whole coin** - no cents anywhere - + and the market's figures match your balance line's formatting (both come + from the server economy's own formatter now). +- [ ] Buying then selling the same good at the same island always LOSES money, + including on the cheapest goods (sand, stone, kelp) where rounding is + proportionally largest. +- [ ] Prices differ by island **type** (a farm sells food cheap) and by **tech** + (high tech sells metals cheap, pays more for ore). +- [ ] Selling a lot of one category into one island visibly depresses its price, + and it recovers over an hour. +- [ ] Tech shows everywhere an island is named: market, chart, warp tooltip, + nav bar. +- [ ] Contraband (sugar) sells only at FRONTIER or rougher, at a premium. +- [ ] `/tw restart` when destitute: fresh kit, chart kept, hold emptied. +- [ ] Charity with no boat and no money: a Bamboo Raft. +- [ ] **Expanders:** sold only at Tech 7, install only into a Pale Oak Chest + Boat, price doubles each time. The nested panel refuses containers, the + TNT refuses a loaded expander, and in a smaller boat they ride inert but + their contents still sell. + +--- + +# Tier 5 — Crime (~25 min) + +- [ ] Entering island space triggers a customs scan at a rate matching the band + (SAFE often, ANARCHIC never). Clean scans announce themselves. +- [ ] Caught with contraband: the patrol launches from the pier and you can see + it coming — flee, fight, or destroy the evidence via the TNT slot. +- [ ] Escaping across the border flags you at that island; a chase that times + out does **not**. +- [ ] Reputation moves on crimes and decays with clean play; the band changes + your scan odds. +- [ ] Wanted: police respond, the bounty shows over your head, and killing a + wanted player pays out exactly once. +- [ ] Police drop nothing, ever, and never hurt bystanders. +- [ ] Fugitives are barred from safe-band markets. + +--- + +# Tier 6 — World, atmosphere, persistence (~20 min) + +- [ ] Islet biomes suit their sea: snowy in frozen water, desert and mangrove in + warm. Cherry grove and pale garden exist somewhere. +- [ ] Surfaces match the biome: desert sand over sandstone, badlands red sand + over terracotta, mangrove mud, old-growth taiga podzol, peaks bare rock. +- [ ] Roughly one islet in four carries a biome-appropriate structure (igloo, + fossils, ruined portal, abandoned camp). Mushroom and pale garden islets + never do. +- [ ] **No jigsaw or structure blocks are visible** in any of them. A ruined + portal still stands on its netherrack footing; a camp tent has no + glowing blocks holding it up. (Find several - the camps and portals + 1/2/4/5 are the ones that carry connectors.) +- [ ] Plaza amenities scale with tech: the galley everywhere, then furnace and + stonecutter, smithing and grindstone, brewing, anvil, and at Tech 7 an + enchanting table with bookshelves. All usable as a visitor, none + breakable. +- [ ] Border curtains: blue at the edge of island space, red at the warp ring, + both passable. Colours follow `border.*`; nonsense values fall back rather + than vanishing. +- [ ] A failed warp: the interstice is lit by braziers, lidded, the ghasts are + visible, and the free re-engage always works. +- [ ] Restart mid-voyage: boats, cargo, fuel, ownership, chart and island stock + all survive. +- [ ] Warp arrival never lands you inside terrain or on the quay. + +--- + +# Regression watchlist + +One-line re-checks for bugs already fixed once. Cheap to run, and every one of +these reached a playtest before. + +- [ ] Placing your own boat and boarding it does **not** offer to capture it. +- [ ] `/tw spawn` while riding: the boat comes with you and keeps its cargo. +- [ ] A warp arrival does not immediately re-open the warp dialog. +- [ ] The DOCK hologram floats above the waterline, never in the sea. +- [ ] Chart holograms do not vanish seconds after a second `/tw chart`. +- [ ] The expander opens by right-click **at sea** (aiming at nothing) as well + as ashore. +- [ ] A wrecked or dropped boat item never despawns on vanilla's 5-minute clock; + it runs its own TTL. +- [ ] Customs patrols actually move (anything past ~160 blocks never ticks). +- [ ] Trident hits and collisions yield the boat as an item, never destroy it. +- [ ] Villagers and golems do not walk into the plaza campfire. diff --git a/TRADEWINDS_SPEC.md b/TRADEWINDS_SPEC.md index ca0c61b..db68eec 100644 --- a/TRADEWINDS_SPEC.md +++ b/TRADEWINDS_SPEC.md @@ -3,33 +3,33 @@ **Name:** TradeWinds (decided — house style, one word, like BSkyBlock/AcidIsland; repo `TradeWinds` under BentoBoxWorld) **Addon ID:** `TradeWinds` · **Package:** `world.bentobox.tradewinds` · **Commands:** `/tw` (admin: `/twadmin`) **Target:** Paper 26.2 (Java 25 runtime, release-21 compile), BentoBox 3.18.1+ -**Author context:** Written for implementation via Claude Code by the BentoBox author. Assume full familiarity with GameModeAddon, WorldSettings, Blueprints, Flags, the BentoBox Database, and the AcidIsland/Poseidon codebases (closest architectural relatives). Companion docs: `tradewinds-overview.md` (pitch), `tradewinds-design-decisions.md` (rationale + open questions), `tradewinds-dev-plan.md` (stage plan). Where this spec and those docs disagree, this spec wins. +**Author context:** Written for implementation via Claude Code by the BentoBox author. Assume full familiarity with GameModeAddon, WorldSettings, Blueprints, Flags, the BentoBox Database, and the AcidIsland/Poseidon codebases (closest architectural relatives). Companion docs: `tradewinds-overview.md` (pitch), `tradewinds-design-decisions.md` (rationale + open questions), `tradewinds-dev-plan.md` (stage plan), **`tradewinds-hold-plan.md` (normative detail for the virtual hold, One Boat rule, boat ranks, dropped-boat lifecycle and expanders — revised 2026-08-01)**. Where this spec and those docs disagree, this spec wins — except that the hold plan is authoritative on hold/boat mechanics. --- ## 1. Concept -A sea-trading game mode: an endless procedurally generated ocean dotted with **NPC trading islands**. Players start with a boat and one trading bundle and get rich buying low and selling high — honestly, or by smuggling, bounty hunting, and piracy. Inspired by TradeWars 2002 and Elite, rebuilt in Minecraft terms. +A sea-trading game mode: an endless procedurally generated ocean dotted with **NPC trading islands**. Players start with a boat — the boat IS the cargo hold — and get rich buying low and selling high — honestly, or by smuggling, bounty hunting, and piracy. Inspired by TradeWars 2002 and Elite, rebuilt in Minecraft terms. | Elite/TW2002 ingredient | TradeWinds equivalent | |---|---| | Sectors / systems | Seeded sparse trading islands in open ocean | | Hyperspace jump + fuel | Boat **warp** between island borders, paid in hold-carried fuel | | Misjump / interdiction | **Interstice**: warp failure drops you in a hostile Nether sea | -| Cargo hold expansion | Bundles → chest boat → shulker "cargo expanders" | +| Cargo hold expansion | 20 boat ranks (Bamboo Raft → Pale Oak Chest Boat) → installed cargo expanders | | Commodity market | BlueBook base prices × island type/biome/security modifiers | | Police / legal status | Reputation bands, customs scans, police mobs, bounties | | Player bases | Purchasable player islands in the open ocean | ### Design principles (load-bearing — do not break) -1. **The fuel/cargo tradeoff is the central mechanic.** Trading transacts **exclusively** against trading bundles and boat hold — never player inventory, ender chests, or carried shulkers. Any feature letting sellable goods bypass the hold breaks the game's spine. +1. **The fuel/cargo tradeoff is the central mechanic.** Trading transacts **exclusively** against the player's **virtual hold** (server-authoritative, database-backed, sized by their one boat — see §4 and `tradewinds-hold-plan.md`) — never player inventory, ender chests, real containers, or anything else. Any feature letting sellable goods bypass the hold breaks the game's spine. Cargo leaves the hold only by being sold or destroyed. 2. **Risk symmetry between travel modes.** Warp: fuel cost, instant, interstice risk. Rowing: free, slow, lawless-ocean risk. Neither may become strictly dominant. 3. **Scans, not prices, balance contraband.** Sugar has infinite trivial supply; the customs-scan risk is the cost. Smuggling is a skill, not a tax. 4. **Crime pays you into danger.** Contraband sells only at less-safe islands; Fugitives are barred from safe-island markets. The richest criminals are structurally pushed into PvP space where bounty hunters lawfully operate. 5. **The seed is the world.** Island positions, existence, types, security bands, biomes, names, and route-graph edge costs are ALL pure deterministic functions of (seed, position) — unit-testable with no Bukkit dependency. Runtime randomness here breaks seed shareability. 6. **Generator-driven terrain, not pasted terrain.** Island landmass comes from the chunk generator (Poseidon-style noise × radial mask). Lazy generation is just chunk generation: no pop-in, no paste pacing. Blueprints/structures decorate; they never create the land. -7. **Scarcity by dimension removal.** No End world → shulker shells unobtainable → cargo expanders are a purchasable, cap-controlled money sink. Never add End access. +7. **Scarcity by purchase-only endgame.** Cargo expanders are shop-only (top-tech ports), price-doubling per unit installed — the endgame money sink; the wallet is the cap. No End world, ever (nothing in the game needs it, and dimension exits break the ocean). ## 2. World Model @@ -51,6 +51,7 @@ A sea-trading game mode: an endless procedurally generated ocean dotted with **N - **Security band:** EVE model — `SAFE`, `POLICED`, `FRONTIER`, `LAWLESS`, `ANARCHIC` (working names; config thresholds). Correlated with local density per above. - **Biome:** whole-island, from a per-type weighted table. Frozen-ocean approaches are a feature (ice = fast boat lanes). - **Name:** Elite-BBC-style procedural token-pair digraph generator, seeded — pronounceable, distinct. + - **Tech level (adopted 2026-08-01):** 1–7, seeded, correlated with type (industrial/luxury skew high, agricultural/fishing low, ±2 variance — "Advanced Agricultural" exists). Starter cluster guarantees at least one ≥ TL3 island. Shown in entry announcement and chart data: *"Esedaxe — Industrial (Tech 6), Safe."* Tech gates **shops only, never docking, riding or crafting**: boat ranks sold ≤ TL × 3 (TL7 = all twenty), expanders sold only at TL7. Tech also modulates prices (±`tech-price-step`, default 3%, per TL step from 4): high-tech sells **finished** goods (metals, food) cheap and buys **raw** (ores, crops, wood, fish, stone) dear; low-tech the inverse — best routes are tech *differentials*, read off the chart as type × tech × security. Higher tech also furnishes the plaza: every port has the galley (workbench + campfire); TL3+ adds furnace + stonecutter, TL4+ smithing table + grindstone, TL5+ brewing stand + cauldron, TL6+ anvil, TL7 an enchanting table ringed with bookshelves. - **Range / protection radius:** AcidIsland framing — range ~1000, protection ~400 (config). - **Reserved space:** cells that rolled empty are candidate sites for purchased player islands (Stage 7). @@ -77,7 +78,7 @@ Free. The border (Border addon in passable/visual mode, or protection-range visu - **Trigger:** player in a boat reaches an island's border (proximity trigger + action-bar prompt; packet-level "click the wall" rejected as needless complexity). Opens the **warp dialog** (Paper dialog API): charted islands listed with per-destination fuel cost; unaffordable/unreachable grayed out. - **Route graph:** seeded per-edge costs; default = Euclidean distance × `fuel-per-block` multiplier; per-edge overrides in config allow cheap "warp lanes" and expensive frontiers without world regen. -- **Fuel:** value table (config): wood < coal/charcoal < coal block < lava bucket (non-stackable = deliberate tension). Consumed **from bundles/boat hold only** (principle 1). Empty buckets returned. +- **Fuel:** value table (config): wood < coal/charcoal < coal block < lava bucket (non-stackable = deliberate tension). Consumed **from the hold's fuel slots only** (principle 1; 7 dedicated slots, fuel never competes with cargo and moves freely both ways). Empty buckets returned. - **Execution** (AcidIsland `/ai` proven pattern): dismount → teleport player + boat cross-world-safe → re-seat. Arrival just inside destination border **on the bearing of the origin island**. Effects: nausea + blindness (durations config) + configurable minor damage ("warping hurts" — gates the under-equipped), portal particles + sound. - **Charting:** dialog lists **charted** islands only. Chart by physically entering an island's range (free, slow); starter cluster pre-charted for new players. Buying chart data = post-MVP. Charting is the discovery layer over lazy generation. @@ -88,23 +89,31 @@ Free. The border (Border addon in passable/visual mode, or protection-range visu - Ghast-vs-boat combat intentionally survivable (fireballs battable). Interstice is a **shared** world (interdiction/ambush meta — very Elite); PvP rules there are an open question (§10). - Stale entity cleanup: interstice mobs despawn on player exit / world empty. -## 4. Cargo & Progression +## 4. Cargo & Progression (canonical, revised 2026-08-01 — full detail in `tradewinds-hold-plan.md`) -- **Start kit:** boat + 1 **trading bundle** (a bundle = one stack's worth of capacity, vanilla bundle rules). -- **Progression:** up to 3 bundles → chest boat → **cargo expanders**: lore-renamed shulker boxes, purchase-only (no End → no crafting), price ~doubling per unit, configurable cap. Expanders live in the chest boat's inventory; their contents count as hold. -- **Hold definition (canonical, revised 2026-07-31):** the player's trading bundles (pouches) + **cargo expanders wherever they are carried** + the chest-boat inventory while riding one. All trade and fuel transactions resolve against the hold only; loose pocket items never count. The hold is *carried*, not moored: a chest boat sitting as an item, or bobbing at a dock, cannot be filled, so the expanders (openable anywhere) are the backbone of cargo capacity and the chest boat is a bonus while sailing. +- **One Boat Rule:** a player has exactly one boat, or none. The hold is the *player's* virtual cargo inventory; the boat they possess only sets its size. Upgrades never move items — the slot count grows, contents stay put. +- **Virtual hold:** server-authoritative, database-backed (`PlayerHold`); **no real container ever holds cargo**. Slot-stack model: up to 21 cargo slots (by boat rank) + 7 fuel slots, auto-consolidated. GUI via BentoBox Panel API: TNT destroy slot, locked-slot panes, fuel row. Opened by right-click while riding, sneak-right-click the boat entity, or right-click the boat item. +- **One-way cargo:** anything can go in (except container items — bundles, shulkers, pouches); cargo leaves only by being **sold** or **destroyed** (TNT slot). Fuel is exempt: freely added and removed, never destroyable. +- **Boat ranks:** 20, Bamboo Raft (2 slots) → Pale Oak Chest Boat (21 slots); config map `boat-material → slots`. Prices quadratic `25 × slots²`. Shops list only bigger boats, gated by island tech (rank ≤ TL × 3); **a purchase replaces and destroys the old boat** (a trade-in at the yard); **a bigger-boat pickup is a swap**: your old boat becomes the floating item, carrying whatever overflow did not fit - nothing is ever lost to the sea. Crafting bypasses tech gates; crafting smaller than current is blocked; adding a chest to the current boat is an in-place upgrade. +- **Start kit:** Oak Boat (rank 2, 3 slots) + starter coal in the fuel slots. Charity/destitution grants a Bamboo Raft. `/tw restart` resets to the start kit. +- **Dropped boats:** a boat item (thrown, death-dropped, or broken loose) carries its hold via a DB record keyed by a PDC UUID, under a DB-persisted TTL. Pickup: bigger = swap (you keep your cargo, salvage merges in, overflow floats on in your old hull); smaller/equal = most-valuable-first transfer into free space, remainder stays claimable; no boat = it becomes yours. Handing a loaded boat to another player this way is sanctioned trade, not an exploit. Boats are destroyed only by lava; death drops the boat with contents (keepInventory keeps it; DeathChest chests it). +- **Cargo expanders:** install only in a Pale Oak Chest Boat, occupy one cargo slot each, open a nested 21-slot panel (net +20; no fuel row, no containers, no nested expanders; TNT-destroyable only when empty). Price `5000 × 2^installed`, no cap — the wallet is the cap. Sold only at TL7 ports. A fully expanded ship approaches ~440 slots: the guard-it-with-your-life endgame. - **Boat ownership:** owner UUID in boat entity PDC from day one (theft/persistence/bounty questions hang off it). ## 5. Economy -### 5.0 The two economies (adopted 2026-07-30) +### 5.0 The two economies (adopted 2026-07-30; stamping removed 2026-08-01) -TradeWinds runs **two parallel economies** connected at only three points: +TradeWinds runs **two parallel economies**, bridged by the hold: -- **The stamped trade economy (money).** Goods bought from traders carry a - **customs stamp** (PDC key `tradewinds:stamp` + lore line; API-visible). - Traders buy ONLY stamped goods - money enters the game exclusively through - trade margins on goods that money already bought. Farming cannot mint money. +- **The trade economy (money).** Customs stamping is **removed entirely**. + Traders buy anything the hold carries, at prices set by island type, tech, + band and the per-island **stock/demand pools** — capacity (a hold slot is + scarce) and pool drift (selling into an island crushes its price toward the + drift floor; pools decay back slowly) are what carry the balance that the + stamp used to. Money still overwhelmingly enters through trade margins, + because hauling bought goods up a price gradient beats farming into a + saturating pool. - **The vanilla survival economy (stuff).** **Wild islets** (small unnamed islands on their own fine grid; `galaxy.wild-islet-*`) are free country: mine, farm, build, sleep. Each rolls its own size and biome, so they range @@ -115,11 +124,12 @@ TradeWinds runs **two parallel economies** connected at only three points: ruins, monuments in the deep basins, buried treasure on the beaches, trial chambers in the rock, and caves under the floor. All protection flags default to allowed outside named islands' protection ranges. Homemade goods are - freely usable (eat, wear, build, burn as warp fuel) but unsellable. -- **The connections:** buying (money->stuff, stamped); **contraband** - (stuff->money: unstamped sugar - and later villagers - are the only - farmable income, balanced by scan risk per principle 3); and self-supply - (homemade food/fuel substitute for purchases). + usable directly (eat, wear, build, burn as warp fuel) and, via the hold, + sellable into the pools. +- **Contraband** remains its own config list (`illegal-trade.contraband-materials`, + default sugar) with the ×premium black-market price at FRONTIER-or-rougher + ports, balanced by scan risk per principle 3 — no longer coupled to any + stamping concept. Survival needs are met by the **outfitter** shelf every island guarantees (bread always, charcoal when the trade catalog carries no fuel, gear by type: @@ -138,7 +148,7 @@ Stage 7 player islands (teams + Bank addon). - **Master config gate** `illegal-trade.enabled` disables ALL illegal-goods mechanics (family servers). Default on. - **Contraband:** sugar (config list; never called drugs anywhere — code, config, locale). **Villager "passengers"** in boats sellable at less-safe islands only (boating the villager is the player's problem — emergent). Gated separately (`illegal-trade.passenger-trade`). - **Customs scan on every entry** into island space — rowed or warped. Chance per security band (SAFE scans often; ANARCHIC never). Per-island scan cooldown prevents re-entry scumming. -- **Detection = chase, not fine:** "customs patrol dispatched" — police launch from the island; ~400 blocks of water is the decision window: **flee** across the border, **fight**, or **jettison** (dumped items float and are lootable by anyone — emergent piracy). **Caught** = police land a hit or close to proximity radius while contraband aboard → confiscation + fine + rep loss. +- **Detection = chase, not fine:** "customs patrol dispatched" — police launch from the island; ~400 blocks of water is the decision window: **flee** across the border, **fight**, or **jettison — destroy-only** (ruled 2026-08-01): dump the evidence into the hold GUI's TNT slot and it is gone, a last-ditch option when you cannot beat the police or escape. Nothing floats. **Caught** = police land a hit or close to proximity radius while contraband aboard → confiscation + fine + rep loss. - **Fleeing a detection flags you** at that island for a cooldown: re-entry skips the roll, police launch immediately. ## 7. Reputation, Police, Bounties @@ -158,7 +168,9 @@ Purchase command: sufficient balance → choose blueprint → placement at a val ## 9. Data Model (BentoBox Database objects) -- `TWPlayerData` (by player UUID): reputation score, bounty, chart set (known island IDs), flags (per-island flee-flags with expiry), scan cooldowns, expander purchase count. +- `TWPlayerData` (by player UUID): reputation score, bounty, chart set (known island IDs), flags (per-island flee-flags with expiry), scan cooldowns. +- `PlayerHold` (by player UUID): boat material (or none), cargo contents `[{material, amount}]`, fuel `[{material, amount}]`, installed expanders with nested contents. +- `DroppedBoat` (by hold UUID, mirrored in the boat item's PDC): boat material, contents, fuel, `expiresAt` TTL. Ownership transitions (drop → pickup → replace) are atomic moves between `PlayerHold` and `DroppedBoat`; duplicating a key item cannot duplicate cargo. - `TWIslandData` (by island ID): galaxy cell coords, type, security band, biome, name, stock levels + price drift map, market state timestamps. - `TWWorldData` (singleton): galaxy seed (authoritative copy; config seed used at first creation then persisted), route-edge overrides cache, bounty ledger totals. - Boat ownership, police tags, loot tags: entity **PDC**, not database. @@ -168,7 +180,7 @@ Purchase command: sufficient balance → choose blueprint → placement at a val 1. **Poseidon mask internals** (Stage 1): mask multiplies noise amplitude vs. lifts a base height — prototype both, pick by coastline quality. 2. **Dock placement** (Stage 2): deterministic terraformed shelf at fixed bearing (lean) vs. jigsaw adaptive. -3. **Boat loss rules** (Stage 4/8): what drops on boat destruction/death? Insurance is the counterweight. Undecided. +3. **Boat loss rules — RESOLVED 2026-08-01** (`tradewinds-hold-plan.md`): boats always drop as an item with hold contents attached (DB-keyed, TTL) — death, trident, collision alike; only lava truly destroys. Insurance remains post-MVP. 4. **Interstice PvP** (Stage 5): is interdicting non-wanted players lawful or itself a rep crime? 5. **Scan/flag tuning** (Stage 6b): cooldown lengths, caught-proximity radius. 6. **Galaxies architecture** (post-MVP): multi-instance addon vs. world manager. Keep world references abstracted from day one. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 962afdf..5ce87d4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,487 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Whole coins, and the economy's own formatter (2026-08-03) + +"Oak Log x1 - $0.97" made every market chart ragged. Two independent faults: + +**We never asked the economy how to write money.** Vault exposes +`fractionalDigits()` and `format(double)`, and BentoBox surfaces the latter as +`VaultHook.format()` - an admin running a whole-coin economy has stated a +preference. TradeWinds hand-formatted `%.2f` in 19 places and overrode them +everywhere. All of it now goes through `economy.Money.format(addon, amount)`, +which delegates to Vault and falls back to whole units when there is no +economy. Two local copies of the same helper (TWFineCommand, CustomsService) +were folded in. + +**Prices are now whole coins.** Naive rounding would have broken the cheap end +- sand/stone/kelp sat at 0.2-0.35 after the produce factor and would have +rounded to FREE - so every money value is scaled x10 (base prices, starting +balance 2500, boats `250 x slots²`, expanders 50000, fines, bounties) and the +unit price is rounded **directionally**: buying `ceil`, selling `floor`, buy +never below 1. The direction is load-bearing: rounding both to nearest lets a +same-island round trip break even on some prices, which is free money. +Verified by brute force over 2000 base values x every type factor - zero +violations - and pinned by `testEveryPriceIsAWholeCoin`. + +Cost of the x10 scale: the cheapest goods carry up to ~10% rounding error +(sand's true 2.07 becomes 3). x20 or x100 would shrink it if play says it +matters. Live servers would need balances scaled to match; ours is wiped +anyway. + +## The boat IS the hold: capture, abandonment, protection (2026-08-02) + +Design session with Ben settled the abandonment rules, and they forced the +structural change the last refit left open: **the hold is now keyed to the +BOAT, not the player.** Capture gives the pirate "everything in it", an +abandoned boat keeps its cargo while you row back to it, and trading needs +the ship present - none of which works with a player-keyed hold. The two +records collapse into one `BoatHold` (material, cargo, fuel, expanders, +owner, last-seen position, item TTL) keyed by a UUID stamped in PDC on BOTH +the entity and its item form; `TWPlayerData` just points at `activeBoat` and +`oldBoat`. `PlayerHold` and `DroppedBoat` are gone. + +**The rules as built:** +- **Trading gate:** no ship inside that island's PROTECTED space, no market. + Carrying the boat as an item counts (it is with you); otherwise the record's + last-seen position answers. +- **Capture by boarding or by pickup**, always behind a confirmation dialog - + because the boat you leave behind keeps YOUR cargo and becomes unowned. + Item pickups cancel first and prompt (15s suppression, since pickup fires + every tick you stand near a hull). Passengers never capture. A player with + no boat claims silently - nothing to lose. +- **Smaller hull:** take the goods, leave the hull to the sea and its TTL. +- **Protection:** an OWNED boat left unattended in protected island space + cannot be boarded or broken by anyone else, mobs included - except at + ANARCHIC ports. **Unowned boats are never protected, anywhere**, which is + what makes an OLD BOAT a race to recover and dying with a loaded ship at a + safe dock genuinely costly. +- **Exactly one OLD BOAT** is remembered; older abandonments are forgotten + flotsam. Reclaiming it clears the marker. +- **Death leaves the boat where it is** - no item drop, still yours. Lava + alone destroys boat and cargo together. +- **Name plates:** `Entity extends Nameable`, so the hull carries the owner's + name or UNOWNED, hidden while ridden. (Vanilla name TAGS only work on mobs; + the API has no such limit.) +- **Respawn/login:** a raft when your own ship is out of reach; a stolen-boat + message and a raft for anyone left treading water at login. +- `/tw chart` now raises BOAT and OLD BOAT holograms anywhere in the world. + +**Trap hit:** `yes:`/`no:` are YAML 1.1 BOOLEANS as keys - the capture dialog +locale keys parsed as `true`/`false` and blew up the locale marker test. +Renamed to confirm/cancel. Never use bare yes/no/on/off as locale keys. + +**Warp arrivals face the dock (2026-08-02):** the boat (and sailor) now leave +a warp pointed at the destination's pier, computed from the same seeded dock +plan the quay is built from, so holding forward is the approach. **A hull +sits ACROSS its yaw, not along it** - the first cut used the plain look-at +yaw and arrived broadside ("turn 90 clockwise and I would be pointing at the +dock"), so `boatYawToward` leads the heading by a quarter turn and normalises +back into Minecraft's (-180, 180]. The plain `yawToward` keeps its own tests +as the honest compass maths. + +**Offered to swap straight back (2026-08-03, playtest):** taking a boat from +the ground leaves the old hull at your feet, and walking over it immediately +asked whether you would like to swap back. The existing per-hull prompt +suppression could not help - the shed hull is a *different* boat, seen for the +first time - so a swap now opens a 5-second quiet window during which boat +items are ignored entirely (the pickup is cancelled, not allowed through, or +the spare would land in the pack). Re-pocketing your OWN boat still works +throughout: that branch runs before the window is checked. + +**Jigsaw blocks left standing in islet structures (2026-08-02, playtest):** a +camp on an islet was held up by a row of glowing jigsaw blocks. The templates +were chosen as "single-piece NBT" precisely to avoid this, but that was wrong: +inspecting the shipped NBT shows the pillager camp features AND ruined portals +1/2/3/4/5 all carry jigsaw connectors. Vanilla's assembler swaps each for its +own `final_state` while building; a raw paste does not. + +Rather than read `final_state` at runtime (Boxed does this via BentoBox's NMS +metadata helper, which needs a real Block and so does not fit a +BlockPopulator), the value is read from each template's NBT once and recorded +on the `Placement`: camps and portal_3 become AIR, portals 1/2/4/5 become +NETHERRACK. `IsletDecorator` then scrubs the pasted box - JIGSAW to that fill, +STRUCTURE blocks to air. A test asserts every placement names a real material +and that the netherrack-footed portals say so. + +**Crafted boat came out blank (2026-08-02, playtest):** an acacia boat crafted +as an upgrade had no lore and would not open. Clicking a crafting result puts +the item on the **cursor**, not into a slot, and the stamping pass only walked +`getInventory().getContents()` - so the new hull never got its record id. It +now checks the cursor first (and retries a few ticks later if the item has not +landed anywhere yet). Because an unstamped hull can never be opened, the hold +GUI also self-heals: right-clicking a boat item with no id, of exactly your +boat's type, while carrying no other avatar of it, adopts it as the avatar. +That repairs any hull that reaches a player without its record, whatever the +route. + +**Chart marking your own pocket (2026-08-02, playtest):** after a few boat +swaps the chart showed BOAT 8m and OLD BOAT 8m - both pointing at the +player's feet, one of them at the boat in their hand. Markers are now only +raised for a boat you actually have to GO to: never one you are carrying +(`BoatService.isCarrying`), never one already under you, and never one within +32 blocks. A hull deliberately shed during a merge (emptied, dropped at your +feet) also stops being charted as an OLD BOAT - you did not lose it, you put +it down. + +**Two boats in one pack (2026-08-02, playtest):** recovering an old boat while +carrying the current one left BOTH hulls in the inventory - and since the +hold GUI matched the boat item by MATERIAL, either oak hull opened the active +hold. The spare could then be dropped, picked up and stripped for free fuel, +or thrown to show up as an OLD BOAT. Three fixes: taking a boat while your +old one is **with you** (carried, or moored within loading range) now pours +its cargo into the new hull and leaves the empty hull at your feet, unowned - +you end up with the best boat and all the cargo, per Ben's ruling; the GUI +gesture matches by boat IDENTITY, not type; and crafted hulls are stamped +with their record, so a freshly built boat is not an anonymous item that +merely looks like your ship. When the old boat is far away nothing changes - +it stays put with its cargo as the OLD BOAT you row back to. + +**Paid $100 for a raft and got nothing (2026-08-02, playtest):** the yard's +purchase only knew how to REFIT - it changed the material on the record the +player already had. With no boat there was no record, so `ifPresent` did +nothing at all: money gone, no hull. Buying with no boat now creates the +record and hands over the item; buying WITH a boat is a genuine refit that +also swaps the avatar in the world (ridden entity replaced under the sailor, +carried item retyped, moored hull rebuilt where it floats) - the old code +changed only the record, so a refit would have left an oak hull claiming to +be a Cherry Chest Boat. A refit now also requires the ship to be AT the yard +(a trade-in needs the old hull present); buying your first boat never does, +since that is the escape hatch for a stranded sailor. + +**Capture left the victim holding a ghost (2026-08-02, two-player playtest):** +one capture produced five bugs, all from a single omission - **taking a boat +never told the boat's owner.** Player 2's record still pointed at the hull +Player 1 had taken, so: no notification at the time; phantom "0/3 slots" at +the market; low-fuel nagging with no boat; and at login the stolen-boat path +called `setActiveBoat(null)`, which *demotes* - so it stripped the new +owner's name (handing the boat back!) and left a bogus OLD BOAT marker 16m +away. The database showed it plainly: both players' `oldBoat` pointed at the +same hull, and Player 1's real abandoned boat was orphaned. + +Fixes: `setActiveBoat` now takes the boat off its previous owner (clearing +their pointer and telling them if online); **losing** a boat goes through a +new `clearActiveBoat` that does NOT demote (only abandonment leaves an OLD +BOAT); `oldBoat()` returns nothing - and forgets the pointer - once the hull +is owned again or gone; fuel warnings skip boatless players; and the market +gate now applies to CARGO only, so the outfitter and shipwright always serve +(a sailor who lost their boat could otherwise never buy another and was +stranded on the island for good). + +**Tests now run the real thing:** these rules had been "verified" against a +hand-written stand-in for HoldManager, which is why none of it was caught. +`TestHolds` now drives the REAL manager over an in-memory database, so the +ownership regressions are pinned against production logic. + +**No BOAT marker while aboard (2026-08-02):** pointing a sailor at the deck +under their feet is noise, so the green BOAT hologram is suppressed while +riding. OLD BOAT still shows - that is the one being rowed back to. + +**Cargo teleported across the ocean (2026-08-02, playtest):** a hull thrown to +a player standing on ANOTHER island stripped itself into their boat thousands +of blocks away, and they could not take the hull at all ("I really wanted the +boat"). Both faults came from the automatic size-based salvage. Finding a hull +- by pickup or by boarding - now always opens one dialog with up to three +answers: take the boat (your own becomes an unowned OLD BOAT), take only the +cargo (**only** while your own boat is within `boats.cargo-transfer-range`, +default 200 - and the dialog says WHY the option is missing when it is not), +or leave it. A boatless finder still claims outright with no dialog. This +supersedes the earlier "smaller hull: goods only, leave the hull" ruling: the +size of the hull no longer decides anything, the player does. + +**Warp facing, settled by console (2026-08-02):** the "hull sits across its +yaw" theory was WRONG and is reverted. The logging proved it in one warp: +arriving (-2919, 2187) with the dock flag at (-3237, 2497), the computed +look-at yaw was 45.8 and the sailor's own F3 read 46.3 when they turned to +face the dock - so `yawToward` had been right from the start. The real fault +is that **mounting drags the facing** (the boat ends up pointing wherever the +player is looking; the log showed player yaw 178.8 against a boat set to +135.8). Both rotations are now forced with `setRotation` two ticks AFTER the +re-seat instead of trusting the teleport, and a regression test pins the real +numbers from that console line. Confirmed working in play; the yaw +diagnostics were removed once they had done their job (the one-line arrival +log stays). Lesson: one empirical report ("turn 90 +clockwise") is a symptom, not a diagnosis - the offset it suggested was a +coincidence of that arrival's geometry. + +**Chart holograms vanishing (2026-08-02):** "/tw chart shows them, then they +quickly disappear." Every `show()` scheduled a fade for the configured +duration, but the timer cleared whatever was current rather than its OWN set +- so an earlier call's timer wiped a later call's holograms seconds after +they appeared. Each set now carries a generation number and a timer only +clears its own. Arrival also raises the chart a second time once the arrival +BLINDNESS wears off, which is why they were "sometimes" invisible on warp in. +Warp arrivals additionally log the dock-flag position, the look-at yaw, the +boat yaw set, and - a tick after the re-seat - what the yaw actually ended up +being, so the remaining facing question can be settled by console rather than +by eye. + +**Playtest round 2 (2026-08-02):** `/tw chart` ashore printed the text list - +useless for the one job ashore actually has, finding your moored boat. The +hologram compass now raises anywhere in the world (text behind +`/tw chart list`). And the capture flow relabelled the abandoned boat BEFORE +`setActiveBoat` stripped its owner, so the plate kept the old owner's name - +relabel now happens after the switch. + +**Playtest immediately after (2026-08-02):** "I dropped the boat in the water +and tried to enter it - it asked me to TAKE my own Oak Boat." Placing a boat +item spawns a plain vanilla entity carrying none of the item's PDC, so the +hull in the water was an unregistered stranger: boarding it registered a new +unowned record and offered a capture, which demoted the real starter boat +(coal and all) to OLD BOAT - hence the phantom marker 56m away and the +missing starter fuel. Fixed with an `EntityPlaceEvent` handler that carries +the record id from item to entity as it is placed. The same identity loss +existed on the teleport path (`BoatPickupListener` built a bare ItemStack), +now stamped; the chest boat's real inventory is no longer rescued there +because the hold is the record. + +241 tests green. + +## The great refit: virtual hold, One Boat, tech levels (2026-08-01) + +The playtest rework, from `tradewinds-hold-plan.md` (normative) + session +rulings. Clean slate - **the test server's TradeWinds DB and world must be +wiped on next deploy** (pouches, stamped goods and real-container holds are +all meaningless now). + +**Tech levels (galaxy).** `IslandSpec.techLevel()` 1-7, seeded, type-based +(+/-2 wobble; INDUSTRIAL/LUXURY base 5, farms/fisheries 2), starter cluster +guarantees one >= TL3 (deterministic boost of the best natural roll if seed +luck fails). Prices tilt by `economy.tech-price-step` (3%/step from TL4): +high tech sells finished (METALS, FOOD) cheap, buys raw (ORES, CROPS, WOOD, +FISH, STONE) dear - contraband exempt. Tech shows in the market subtitle, +warp tooltips, chart, holograms, nav bar. Plazas furnish by tech: galley +everywhere; furnace+stonecutter TL3, smithing+grindstone TL4, brewing+cauldron +TL5, anvil TL6, enchanting table with bookshelf arc TL7. + +**The virtual hold.** `PlayerHold` (DB): boat, cargo material→amount, fuel, +expanders. No real container ever holds cargo; ItemStack meta does not +survive deposit (by design - cargo is a commodity). `HoldService` is the only +gate: slots = ceil(amount/stack) consolidated; one-way in (containers, +bundles, shulkers and BOATS refused); out = sell or TNT-destroy only; 7 fuel +slots, fuel free both ways. `HoldGui`: 54-slot render-and-command window - +every click cancelled and interpreted (deposit from pack, select stack → TNT, +fuel withdraw, expander panels). Chest-boat REAL inventories are now +unreachable by design (sneak-click and in-boat right-click open the hold GUI +instead) - no shadow storage. + +**One Boat.** 20 ranks (config `boats.ranks`), Bamboo Raft 2 slots → Pale Oak +Chest Boat 21. Shop: quadratic `25 x slots²`, lists bigger-only, gated +rank <= TL x 3; purchase REPLACES and destroys the old boat, contents stay. +Crafting: smaller-or-equal refused, bigger is a replacement upgrade +(chest-add included); tech never gates crafting. Start kit: Oak Boat + coal +in the fuel slots. Charity: Bamboo Raft. `/tw restart` clears the whole hold. + +**Stamping is GONE.** Contraband has its own list +(`illegal-trade.contraband-materials`); the market buys anything aboard. +Balance now rests on capacity + stock-pool drift - **watch the pools in +playtest** (farm-and-sell is legal now; drift-scale 500 / decay 50/hr may +need tightening). + +**Dropped boats.** Boat items carry their hold via a PDC UUID onto a +`DroppedBoat` record; DB-persisted TTL (`boats.dropped-boat-ttl-minutes`), +sweep task sinks expired flotsam; vanilla despawn cancelled for tagged items. +Breakage always yields the item (destroy event cancelled, entity swapped for +a drop) - lava alone burns boat and hold. Death drops the tagged boat +(keepInventory keeps it; DeathChest chests it; fire/lava deaths burn it). +Pickup: no boat = claim whole; bigger = SWAP (ruled in session: your old boat +becomes the floating item, carrying whatever overflow did not fit - the old +hull can always take it, so nothing is ever lost to the sea); smaller/equal = +most-valuable-first partial transfer, remainder stays claimable. Handing a +loaded boat over IS the trade gesture. Shop purchases remain trade-ins (old +boat destroyed) - only salvage swaps. + +**Expanders v2.** Install-only into a Pale Oak Chest Boat with a free slot +(each occupies one), TL7 shops only, `5000 x 2^installed`, no cap. Nested +21-slot panel per expander (own TNT, no fuel row, no nesting); TNT refuses a +loaded expander; in any smaller boat they ride inert, contents intact, still +counted aboard for trade and customs. + +**Post-rulings (same session):** riding + inventory key opens the hold on +CHEST-variant boats (the client sends the vanilla open-vehicle-inventory +request and we redirect it - this also seals the chest boat's real inventory +completely). Plain boats CANNOT get this gesture: the client renders the +ordinary inventory screen without telling the server, so the right-click +gestures cover them. Also: bigger-boat salvage is a SWAP (old boat +floats on with the overflow - nothing lost to the sea, conservation-tested); +fuel-valued cargo clicks MOVE to the fuel row instead of selecting for the +TNT (fuel is exempt from one-way, and the TNT never touches fuel, per plan). + +**Respawn loaner (2026-08-02):** a boatless respawner is lent +`boats.respawn-boat` (default BAMBOO_RAFT, NONE disables) - death maroons you +ashore while your boat floats at the death site; the raft is the row back. +Never granted over an existing boat (keepInventory), and off-ladder config +values disable rather than misfire. + +**Duplicate locale keys (2026-08-02):** the console warned "duplicate keys +found : description / nothing-to-sell". Both were self-inflicted by bulk +locale edits: the `commands.trade` subcommand had been left indented under +`starchart` (so its `description` collided and the trade command lost its +help line), and renaming `not-stamped` to `nothing-to-sell` collided with an +existing key of that name - two different messages, one for "this port wants +none of what you carry" (dialog) and one for "you have none of THAT" +(market), so the market's got its own key `nothing-of-that`. Bukkit only +WARNS and then silently keeps one, so `ResourceYamlTest` now fails the build +on any duplicate key in addon.yml, config.yml or any locale. + +**Plain-boat pickups + no lingering hulls (2026-08-02, playtest):** "picking +up a given oak chest boat didn't upgrade." The ladder now applies to UNTAGGED +boat items too (command-given, spare hulls, other players' plain drops): +bigger replaces (no record = no overflow = old hull consumed outright), +boatless claims, smaller stays an ordinary item. And the salvage swap only +leaves the old boat afloat when it actually carries overflow - an empty old +hull no longer lingers. + +**Visible boundaries (2026-08-02, playtest):** "there is no visible border - +it's invisible." A per-player dust-curtain task now paints the arc nearest +you of both rings: RED at the warp-offer ring (protection edge), BLUE at the +island-space edge. Colors/view distance/master switch in `border.*`; parsing +is forgiving (typo = default, never a stripped border). Paint, not a wall. + +**First cut showed nothing** (warp dialog fired, no particles). Rewritten to +follow the Border addon's proven path exactly: `User.spawnParticle` (which +resolves DUST/REDSTONE across versions, validates the dust options, applies +the server view-distance check and passes extra=1 - a raw +`player.spawnParticle(..., extra 0, dust)` was the difference), and the +curtain now hangs around the PLAYER's own Y rather than an assumed sea level, +so a boat sitting above the waterline still sees it. Startup logs a line with +the ring radii and on/off state, so the console says whether it is alive. +Ruled out first: config default (BentoBox `config.contains` gating means a +missing path keeps the Java default `true`, so an old config cannot silently +disable it) and task registration (start() is in onEnable). + +**Removed:** pouches, customs stamps, the carried-shulker expander, +`ExpanderListener`, `economy.unstamped-sellables`, `stamp-glint`, +`expander-cap`, `max-bundles`, `pouch-price`, `TWPlayerData.expandersPurchased`. + +229 tests green. + +## The galley: every plaza cooks (2026-08-01) + +Every market plaza now has a **public workbench and a campfire hearth** beside +the quay entrance (bearing + 0.7 rad, plazaRadius − 4), so a sailor can craft +and cook their catch the moment they step ashore. No flag changes were needed: +CRAFTING is already visitor-rank at every port, campfire interaction is +governed by BentoBox's FURNACE flag (also visitor-rank), and BREAK_BLOCKS is +denied - usable by all, removable by none. The campfire stands on a +cobblestone hearth block so residents do not path across open flame. A +campfire rather than a furnace on purpose: it needs no fuel, so even a +destitute sailor can cook. No economy leak either way - anything crafted or +cooked is unstamped and therefore unsellable; the galley feeds players, not +wallets. + +226 tests green. + +## Ground truth, and ruins worth rowing to (2026-08-01) + +**Per-biome surface materials.** With every biome now generatable, "everything +stands on grass" stopped being ignorable: a desert islet was a lawn with a +desert sky. `surfaceKindAt` is now purely biome-driven - it asks `biomeKeyAt` +(which already knows islet vs island vs beach fringe vs mushroom) and maps +through one table: sand under deserts and beaches, red sand over terracotta in +the badlands, podzol in old-growth taiga, mud in mangrove swamps, bare stone +on stony shores and the peaks, gravel on the gravelly hills, snow blocks on +groves/slopes/ice spikes, mycelium on mushroom fields. Trading islands follow +the same rule (a desert INDUSTRIAL port is a sand island now); plaza and dock +terraforming still override their own columns. `SurfaceKind` grew from 3 kinds +to 9; `IslandPalette` maps them (subsoil too: sandstone under sand, terracotta +under red sand). NOTE for the live world: the spawn island's mangrove biome +means its newly generated chunks are mud-surfaced - a seam against +already-generated grass chunks is expected on the test server. + +**Islet structures.** Wild islets can now carry a small vanilla structure at +their heart, chance per islet in config (`galaxy.islet-structure-chance`, +default 0.25). Selection is pure and seeded (`galaxy.IsletStructures`): +igloos on the snowfields, half-buried fossils in the deserts/badlands/swamps, +ruined portals (loot chests intact) in jungles and woods, abandoned pillager +camps (tents, log piles, target ranges) on plains and savannas. Placement is +`generator.IsletDecorator`, a BlockPopulator that stamps the whole template +down when the islet's center chunk generates - single-chunk anchored, so no +cross-chunk consistency to maintain, and safe to toggle mid-game. +**Deliberately restricted to vanilla's single-piece NBT templates** (fossils, +igloo/top, ruined_portal/*, pillager_outpost/feature_*) - jigsaw-built +structures (villages, temples) would leave connector blocks behind if placed +raw; that is Stage-2 territory via the Boxed patterns. Mushroom islets and +pale gardens never decorate: there the biome itself is the find. + +226 tests green. + +## Every land biome now has somewhere to exist (2026-08-01) + +"Is it possible to find a Cherry Grove islet or Pale Oak islet?" It was not: +wild islets drew uniformly from a list of 8 temperate biomes, and 21 of the +registry's 40 generatable overworld land biomes existed nowhere at all +(pale_garden, taiga, grove, bamboo_jungle, eroded_badlands, the peaks...). + +Wild islets now draw from **temperature-banded lists** keyed to +`oceanTemperatureIndex` at the islet's center - the sea the galaxy already +varies. Frozen seas grow snowfields, groves and frozen peaks; cold seas taigas +and windswept hills; temperate seas the forests (cherry grove and pale garden +among them); lukewarm the savannas and sparse jungle; warm seas desert, +badlands, bamboo jungle and mangrove. Between the five bands and the trading +island types, every land biome is reachable (caves, rivers, oceans, Nether and +End deliberately excepted), and the land always suits the water it stands in. +`isletBiomes()` aggregates the bands, so the biome provider declares the new +keys with no further change. Trading island type biomes are untouched. + +Caveat for the live world: islet biomes in not-yet-generated chunks re-roll +under the new scheme, so a chunk border through an already-half-generated +islet could show a biome seam. Trading islands are unaffected. + +220 tests green. + +## The expander that would not open (2026-08-01) + +**"Right clicking a cargo expander does not open its inventory."** Two +findings, one report: + +**The gesture players actually try was never implemented.** The listener only +knew the in-hand gesture (expander in the main hand, right-click the world); +the natural one - inventory screen open, right-click the expander where it +lies, like using a bundle - fell through to vanilla's pick-up-half. There is +now an `InventoryClickEvent` handler for it: own inventory only, empty cursor +only, opens the same 27-slot view next tick. The view write-back is tracked by +SLOT, and registration now happens only after `openInventory` confirms the +view really opened - registering first meant the close of a view being swapped +out could have its contents written into the box. + +**The in-hand gesture was also broken, invisibly.** `PlayerInteractEvent` for +a click at AIR is *born cancelled* - `isCancelled()` is defined as "block use +denied", and with no block that half is permanently denied - so the +`ignoreCancelled = true` on `onRightClick` skipped every click at sea or at +the horizon. The handler now checks the half that means something for a held +item: `useItemInHand() == DENY`. **Pitfall for every future +PlayerInteractEvent listener: never pair `ignoreCancelled = true` with +RIGHT_CLICK_AIR handling.** + +218 tests green. + +## Arrivals reopened the warp dialog, and the dock sign sank (2026-08-01) + +**"Warping in triggered the exit warp."** Moving arrivals to the border (below) +overshot: 400 blocks is exactly the protection range, which is exactly where +the warp-offer ring sits - so every arrival landed ON the ring and the dialog +the sailor had just come through reopened in their face. Two fixes, either of +which suffices: the arrival default is now 320 (inside the ring's inner edge at +protection − trigger = 370, with margin for the outward open-water correction), +and `BorderPromptListener` listens for `TWWarpCompletedEvent` and seeds its own +prompt cooldown at the destination - so even a config that lands arrivals on +the ring cannot reopen the dialog. Live configs need `arrival-distance: 320` by +hand; the cooldown seeding protects the ones that keep 400. + +**"The DOCK sign is in the water."** Chart hologram heights are relative to the +player's feet, and a sailor in a boat has their feet at sea level - the dock +marker's 0.2 offset put it *in* the sea ten blocks out, below the horizon. +Dock marker raised to 1.5 and the island-name stack base to 2.5, preserving the +design (dock lowest, names stacked above, 0.8 per rank) with everything above +the waterline. + +Also learned in the same playtest: the SAFE-cluster produce→demand triangle +(Belege → Teenla → Esedaxe on the test seed) earns as designed - "a bit of a +grind, but makes safe money." + +211 tests green. + ## Patrols that never ticked, and a false escape (2026-08-01) The dispatch logging earned its place immediately - one paste of console output diff --git a/docs/TESTING-archive.md b/docs/TESTING-archive.md new file mode 100644 index 0000000..25a6576 --- /dev/null +++ b/docs/TESTING-archive.md @@ -0,0 +1,1301 @@ +# TradeWinds — Manual Test Archive (superseded) + +*Kept for the record only. This is the chronological, stage-by-stage checklist +as it stood on 2026-08-02, before it was reorganised by risk into `TESTING.md`. +Much of it tests mechanics that no longer exist - trading pouches, customs +stamps, carried-shulker expanders, the chest boat as a hold - and the notes +under ticked items record bugs already fixed. Use `TESTING.md` to test; use +this to remember what was checked and why.* + +# TradeWinds — Manual In-Game Test Checklists + +Per-stage manual verification on the test server (`/Users/ben/Minecraft/26.2`). +Deploy: `cp target/TradeWinds-*-LOCAL.jar /Users/ben/Minecraft/26.2/plugins/BentoBox/addons/` +then restart the server. Automated coverage lives in `src/test`; this file is for +what only a live server can prove. + +## Stage 0 — Addon scaffold + +- [x] Server starts with no errors/warnings from TradeWinds in the console. +- [x] `bbox version` lists TradeWinds alongside the other gamemodes (AcidIsland, AOneBlock, Gusher…), state ENABLED. +- [x] Worlds `tradewinds_world` and `tradewinds_world_nether` exist (`mv list` / console log). +- [x] `/tw` (and `/tradewinds`) teleports the player to spawn in open ocean — water to the horizon, no land, no vanilla continents. + - ~~Fail - Running `/tw` reports the error "There is no spawn in this gamemode" and nothing happens.~~ + - ~~Running `/tw create` makes an island for the player with a bedrock.~~ + - FIXED (retest): `/tw spawn` is now a TradeWinds command that teleports straight to the + world spawn on the sea surface (no spawn island needed); `create`/`reset` are removed — + player islands are purchase-only (Stage 7). Also retest: `/tw create` must now be + an unknown command. Delete the stray bedrock island from the earlier test with + `/twadmin delete `. +- [x] Ocean floor exists (dive down: sand/sandstone floor roughly y25–y50, bedrock at bottom, no caves by default). +- [x] Above-water world is air up to build height (no floating junk). +- [x] The interstice is inaccessible: nether portals in `tradewinds_world` do not activate/link (build one and light it), and no command teleports there. + - ~~FAIL: I built a portal and was able to port there.~~ + - FIXED (retest): a portal listener now cancels portal creation AND portal teleports in + both TradeWinds worlds (the config flag only stopped BentoBox's own linking, not + Multiverse's). Retest: lighting an obsidian frame should do nothing at all; the + portal built during the earlier test should also no longer teleport. +- [x] Interstice world (teleport there as admin, e.g. `mv tp`): water sea over basalt/soul-sand floor, nether ambience. +- [x] No `tradewinds_world_the_end` world is created. +- [x] `/twadmin` responds (admin help). +- [x] `addons/TradeWinds/config.yml` generated with all Stage 0 sections (galaxy, travel, illegal-trade, world). +- [x] Restart the server: worlds reload, no duplicate-world or generator errors, chunks unchanged (fly the same area). +- [x] Other gamemodes still work (create/visit an AcidIsland island). + +## Stage 1 — Seeded galaxy + +Set `galaxy.seed` in `addons/TradeWinds/config.yml` to a known value (e.g. `20260729`) +and delete the `tradewinds_world*` folders for a clean generation, then: + +- [x] Console logs `TradeWinds galaxy seed: ` on first world access. +- [x] Use `/twadmin islands` to list the 10 nearest trading islands (works before any + terrain generates — it queries the galaxy engine), then `/twadmin tpisland 1` to + visit the nearest. The registration log line (name, type, band, coords) appears + when the island's center chunk loads, i.e. on arrival. + - ~~FAIL - no islands logged in console. Cannot TP to any islands either.~~ + - Not a generation bug: islands register lazily when their center chunk first loads, + and nothing had generated chunks 3.5k+ blocks out. The missing piece was a discovery + tool — hence the two new admin commands above. +- [x] Islands rise smoothly from the ocean: underwater shelf → beach → grassy interior; no cliffs of floating terrain, no chunk-border seams, no pop-in (land is generated, not pasted). +- [x] Each island has a single whole-island biome matching its logged type (e.g. MINING → windswept hills; FROZEN → snowy, with **ice sheets in the surrounding water ring** — ride a boat over the ice: it should be fast). +- [x] Entering an island's protection range announces its name ("Now entering "). +- [x] `bbox` island info at an island (`/twadmin info` while standing there) shows an unowned island, range 1000, protection 400. +- [x] Restart the server: the same islands are still registered (no duplicate-registration log lines), names unchanged. +- [x] Regenerate the world from scratch with the same seed (stop server, delete world folders AND `database/` TradeWinds islands): identical island positions, names, types. +- [x] Islands are never within sight of one another (min separation 2500). +- [x] PvP setting: on a LAWLESS/ANARCHIC island the island PVP flag is on; on SAFE it is off. + +## Stage 2 — Island content + +**IMPORTANT: needs freshly generated island chunks.** Docks/plazas/villagers only appear +in chunks generated by this build — visit islands you have NOT been to before +(`/twadmin islands`, pick ones beyond previous exploration), or regenerate the world. + +- [x] Every island visited has a **dock**: a straight stone-brick quay with a plank deck + one block above the water, running from the island's flank out into open water. + A boat can pull up alongside the deck end. + - ~~see screenshot at /Users/ben/Downloads/2026-07-29_16.40.38.png. The dock always has a gap between it and the land. It'd be better to have it join the land, otherwise it's not going to be used much.~~ + - FIXED (retest, needs fresh chunks): the plaza's blend ring took precedence over the + dock strip, so the ring blended down to submerged terrain on the seaward side and + cut the quay off ~10 blocks from shore. The dock strip now wins over the ring: the + deck runs unbroken from the plaza edge to the pier end (one step down from plaza + to deck). A regression test walks the full dock axis on 12 islands. +- [x] The dock's plank type varies by island type (oak/spruce/acacia/cherry/dark oak). +- [x] Inland of the dock: a flattened **market plaza** (dirt-path disc) with a bell at + the center, four lantern posts, and 2–4 market stalls (fence posts + colored wool + canopy + barrel). Stall canopy color matches the island type. +- [x] Plaza edges blend into the terrain — no sheer walls around the plaza disc. +- [x] **Villagers** (3–5) stand on the plaza, professions matching the island's economy + (FISHING → fishermen; AGRICULTURAL → farmer/butcher/shepherd; MINING → mason/toolsmith; + INDUSTRIAL → smiths; LUXURY → librarian/cleric; …). Skin variant matches the biome + (snowy islands → snow villagers, desert → desert, …). + - ~~FAIL - they are always Fishermen, or no profession.~~ + - FIXED (retest, needs fresh chunks): vanilla resets a zero-XP villager with no + claimed job site to unemployed — and the stall barrels are fisherman job sites, + so the rest converged on Fisherman. Spawned villagers now carry 1 trade XP, + which locks the assigned profession permanently. Existing villagers from the + previous build will stay broken; only newly generated islands count. +- [x] Villagers are on the plaza, NOT at the waterline (shoreline-safety rule). +- [x] **Iron golems** on the plaza: 3 on SAFE, 2 on POLICED, 1 on FRONTIER/LAWLESS, + none on ANARCHIC. +- [x] Leave (unload chunks) and return: villagers and golems are still there (persistent, + no despawn). +- [x] Restart the server and revisit: residents persist, and no duplicate set spawns. +- [x] Same seed regeneration: dock bearing, plaza position, and stall layout are identical. + +## Stage 2b — Island identity (type landmarks, dressing, configurable weights) + +Playtest feedback: islands looked the same apart from biome; INDUSTRIAL didn't read +industrial. **Needs freshly generated island chunks**, as ever. + +- [x] Each island has a **type landmark** on the inland edge of the plaza (opposite the dock): + - INDUSTRIAL: brick chimney with a smoking signal fire on top (visible from the sea!), + blast furnaces, anvil, coal/iron block piles at the foot. + - MINING: timbered shaft head, rails, spoil heap, exposed ore blocks. + - AGRICULTURAL: fenced wheat plot with irrigation channel, hay-bale stack. + - FISHING: smokehouse campfire + barrel stack, and a moored rowboat at the pier end. + - FOREST: log pile with stripped logs. + - LUXURY: chiseled-quartz fountain with potted flowers. + - FROZEN: packed-ice beacon cairn with a lantern. +- [x] **Plaza surface** varies by type: polished blackstone (INDUSTRIAL), smooth quartz + (LUXURY), cobblestone (MINING), podzol (FOREST), planks (FISHING/FROZEN), + dirt path (AGRICULTURAL). +- [x] A **type-colored banner** and lantern post fly at the seaward end of every quay — + the island's "flag" as you approach by boat. +- [x] **Workstations** beside each stall (blast furnace/grindstone/anvil on INDUSTRIAL, + lectern/brewing stand on LUXURY, composter/smoker/loom on AGRICULTURAL, …). +- [x] Villagers do NOT lose or change their professions near the workstations (locked by XP). +- [x] `galaxy.type-weights` appears in config.yml; setting a type's weight to 0 and + regenerating removes that type (needs world regen — weights are seed-shaping). + +## Stage 3 — Travel: warp, fuel, charting + +Get a chest boat, put fuel in it (coal is 8 units, a coal block 80, logs 1, +a lava bucket 100 — see `travel.fuel-values`), and: + +- [ ] **Charting**: joining fresh, `/tw chart` already lists the starter-cluster islands + (pre-charted). Rowing into a NEW island's waters (range 1000) pops "Charted !" + on the action bar, and it appears in `/tw chart` with type/band/distance. +- [ ] **Warp offer**: rowing a boat across an island's VISIBLE border (the protection + edge, ~400 from center — the moment "Now leaving " appears) pops the warp + dialog automatically (once per 30s per island). `/tw warp` opens it anywhere + inside an island's waters while boated. + - ~~Playtest: no offer when rowing out past the leaving message~~ FIXED: the + trigger sat at the far range edge (~1000), 550 blocks of empty ocean later. + Now it fires right at the visible border. (Fuel never gates the offer — + unaffordable routes show greyed out.) +- [ ] The dialog lists **charted islands only** (uncharted never appear), nearest first, + with fuel cost per destination; the body shows fuel aboard. Unaffordable entries + are dark grey and clicking them just says "not enough fuel". +- [ ] **Warp**: clicking an affordable destination consumes fuel from the chest boat / + bundles (check the inventory after), plays portal particles/sound, dismounts, + teleports player AND boat, re-seats the player in the boat, applies ~8s nausea + + 3s blindness + 1 heart damage, and lands **~130 blocks from the destination's + center on the side facing the origin island** — the island is right in front of + you at default view distance (`travel.warp.arrival-distance`). + - Playtest: 350 was too far — a boring paddle, and easy to get lost even with + the HUD. Retuned to 130 per test. +- [ ] Fuel in the player's pockets (not in chest boat or bundle) does NOT count and is + never consumed — hold-only is the spec's core rule. +- [ ] Lava bucket burns to an empty bucket (bucket stays aboard). +- [ ] Fuel costs match distance × 0.01 rounded up (check two islands at a known distance + via `/twadmin islands`); add an entry under `travel.warp.edge-overrides` + (e.g. `"0,0>0,1": 1`), reload, and see that edge cost change. +- [ ] Warp back: the full row-out → warp → warp-back loop works, including re-seating. +- [ ] Chart persists across relog and server restart. +- [ ] A player without a boat: `/tw warp` refuses ("aboard a boat"); the border prompt + does not fire while swimming. + +## Stage 3b — Resident protection, band flags, navigation bar, starter kit + +Playtest feedback round: traders died to night mobs and scattered; no way to find the +dock after warping; spawn dropped you in open water with nothing. + +**Resident protection (works on EXISTING islands too — no fresh chunks needed for +the listener/tether; respawn accounting also applies everywhere):** +- [ ] Stand on a FRONTIER+ island at night: hostile mobs spawn but never chase or hit + villagers/golems; a zombie walks straight past the traders. +- [ ] On SAFE/POLICED islands: no hostile mobs spawn inside the protection range at + all (outside the 400 radius and in open ocean they still do). +- [ ] Hit a villager to make it flee: within ~30s the tether teleports it back to + the plaza. +- [ ] Kill a villager (on a non-SAFE island — on SAFE the hit is blocked outright by + the HURT_VILLAGERS flag): after ~10 minutes with the plaza loaded, a replacement + spawns at the plaza with the right profession (console logs the respawn). +- [ ] `/tw settings` while standing on a trading island: any player can VIEW the flags; + toggling is refused (unowned island, no rank). `/twadmin settings` edits work. +- [ ] Change a `bands.*` config value, `/twadmin reload` (or restart), `/twadmin reflag`: + existing islands pick up the new flags. + +**Navigation boss bar:** +- [ ] Entering any island's waters shows a boss bar: island name | Clean | Dock m. +- [ ] The distance falls as you boat toward the dock and the bar fills; it updates + about once a second. +- [ ] Bar color tracks the band: blue SAFE, green POLICED, yellow FRONTIER, red + LAWLESS, purple ANARCHIC. +- [ ] Leaving island waters (or the world) removes the bar. `hud.navigation-bossbar: + false` disables it entirely. + +**Creeper griefing (playtest feedback):** +- [ ] Lure a creeper next to a market stall / landmark and let it explode: **no blocks + break** anywhere in the world, but the blast still damages you (and would damage + other players). Residents take no damage (mob damage is blocked for them). +- [ ] `world.flags` in config.yml shows `CREEPER_DAMAGE: false`, `CREEPER_GRIEFING: true` + after a restart (the addon re-asserts these each enable). + +**Starter kit:** +- [ ] A brand-new player's first `/tw` arrival: gets a gold-named "Trading Bundle", + and — since spawn is water — an oak boat appears with them seated in it. + Kit message shows once, ever (relog + `/tw spawn` again: no second kit). +- [ ] The starter boat has the owner recorded (no visible check yet — Stage 4+ uses it). +- [ ] Returning to spawn later WITH a boat item in inventory: it is placed and you are + seated (item consumed). Without one: you swim. + +## Stage 4 — Economy and cargo + +Vault + an economy plugin must be running (they are on the test server). Base prices +come from the embedded price engine: the config table (`economy.base-prices`) plus +recipe derivation — e.g. HAY_BLOCK derives from 9x wheat even if unlisted, and a +crafted item made of priced parts is automatically sellable. + +- [ ] New player: starter kit now also deposits the starting balance ($250). +- [ ] **Open the market**: right-click any resident villager, or `/tw trade` anywhere + within an island's protection range. Main menu shows balance, island type/band, + and Sell / Buy / Shipwright buttons. +- [ ] **Hold-only is enforced**: with wheat in your POCKETS and none in the hold, the + sell page says there is nothing to sell. Move it to the chest boat (or a bundle, + max 3 count) and it appears. This is the single most important economy check. +- [ ] **Buy low**: at an AGRICULTURAL island, crops are cheap (produce factor). Buy 16 + wheat — money leaves Vault balance, wheat lands in the chest boat. +- [ ] **Sell high**: haul it to an island that demands CROPS (FISHING) — the sell price + there beats what you paid. A same-island buy-then-sell always loses money. +- [ ] **Margins scale with danger**: the same demanded good pays visibly more at a + FRONTIER/LAWLESS island than at a SAFE one (band demand bonus). +- [ ] **Stock drift**: sell a large amount of one category at one island — its prices + for that category drop. Buy an island out — prices rise. Restart the server: + the drift persists (TWIslandData in the database). +- [ ] **LUXURY islands** sell nothing (no Buy button) but pay handsomely for gems, + luxuries, and fish. +- [ ] **Shipwright**: buy a Cargo Expander ($5000) — a gold-named shulker box placed + into your chest boat. The next one costs $10,000; the cap (4) refuses further + purchases. Goods inside the expander count for selling and fuel. +- [ ] Expanders cannot be crafted (no shulker shells exist — no End). +- [ ] A 4th bundle in your inventory does NOT add hold space (max-bundles 3). +- [ ] Without a chest boat and bundles, buying refuses with "no room in your hold". + +### Stage 4b — trade screen UX + embedded pricing (feedback round) +- [ ] Every market screen shows "Balance: $X | Hold space: ~N items", updating after + each transaction. +- [ ] Sell and Buy pages have a "< Back" button returning to the main menu; the main + menu has "Close". +- [ ] Recipe-derived pricing works: an item NOT in the base price table but craftable + from priced parts (e.g. a HAY_BLOCK, CRAFTING_TABLE from planks) gets a sane + sell price; junk with no priceable recipe (e.g. a stray BEDROCK) is untradeable. + +### Stage 4c — Spawn islet and safe respawn (playtest fix) + +**Existing worlds**: the islet only appears in freshly generated chunks. Stop the +server and delete the four region files around the origin +(`tradewinds_world/region/r.0.0.mca`, `r.-1.0.mca`, `r.0.-1.mca`, `r.-1.-1.mca`) — +everything else (islands, database) is untouched; the area regenerates with the islet. + +- [ ] A grassy plains islet (~96 blocks across) exists at 0,0; world spawn is on top + of it. +- [ ] `/tw` teleports onto the islet (dry land). First-time players get the bundle and + a boat ITEM (spawn is land now, not water). +- [ ] Die without a bed: you respawn ON the islet — not at 0,42,0 in the seabed, and + not drowning. Repeat deaths respawn there every time. +- [ ] Die with a bed set somewhere: the bed is honored. +- [ ] Dying in the interstice (admin-teleport there) without a bed also returns you to + the islet. + +### Stage 4d — trade quantities (feedback round) +- [ ] Main menu: with an empty hold (or nothing this island pays for), there is NO + "Sell cargo" button; it appears once sellable cargo is aboard. +- [ ] Sell page: each cargo type is one ROW of three buttons — "x1", "x16", + "All - $total" — selling exactly that many (x16 with only 9 aboard sells 9). +- [ ] Buy page: each catalog good is a row of "x1 / x16 / x64" at quoted totals, + still limited by balance and hold space. +- [ ] More than 8 cargo types: the sell page shows the first 8 with a note; selling + some reveals the rest. + +## Stage 4e — Rower navigation: chart holograms + Star Chart + +- [ ] **Hologram compass**: in a boat, `/tw chart` raises floating name-tags around you, + each hanging in the true direction of a charted island (name / type, band / + distance). They start at you and glide out to a ~10 block ring. +- [ ] Islands sharing a bearing stack vertically — nearest lowest, further ones above. +- [ ] Holograms fade after ~15s (`chart.hologram-duration-seconds`); re-running the + command replaces them; only YOU see them (check with a second account). +- [ ] Ashore (not in a boat) or with `/tw chart list`: the text list as before. +- [ ] **Star Chart**: `/tw starchart` gives a map item. Holding it: dark-blue ocean, + you centered as a rotating arrow, charted islands as band-colored dots with + names. Islands beyond the map edge are pinned AT the edge with their name — + a heading hint until you get closer. +- [ ] The chart follows you as you row (you stay centered); scale via + `chart.starchart-blocks-per-pixel` (64 = ~8km across). +- [ ] Restart the server: an existing Star Chart item still renders (the view id + persists and the renderer re-attaches). + +### Stage 4f — the fuel guarantee +- [ ] Every island's Buy page includes at least one fuel: FOREST sells logs (and + charcoal is classified as wood, so forest charcoal is cheap), MINING sells coal, + and everywhere else — including LUXURY, which otherwise sells nothing — CHARCOAL + appears as the fallback line, priced like any commodity. +- [ ] With money but no fuel, you can always refuel at whatever island you are at. + +## Stage 4g — Two economies: stamps, outfitters, wild islets, restart + +**World note**: wild islets appear only in freshly generated chunks (empty cells you +have not visited). The protection flip and stamps work immediately. + +- [ ] **Customs stamp**: goods bought from any market carry a lore line "⚓ Customs + Stamped" (PDC-marked; `economy.stamp-glint: true` adds a glint). Stamped and + identical unstamped items never stack together. +- [ ] Only stamped goods appear on the Sell page. Homegrown wheat/mined ore in the + hold: not offered, and "traders only buy customs-stamped goods" if forced. +- [ ] **Exception**: unstamped SUGAR sells fine (the smallholder economy) — unless + `illegal-trade.enabled: false`. +- [ ] **Outfitter** button at every market: bread always; charcoal where the trade + catalog has no fuel; INDUSTRIAL sells iron sword/shield/armor; farms sell beds; + FISHING sells rods; MINING an iron pickaxe; LUXURY golden apples. All priced by + the engine (gear prices derive from their recipes). + - ~~BUG: buying a fishing rod was refused for "no hold space" with 8 free~~ + FIXED: outfitter stores now go to your INVENTORY, not the hold, and are NOT + stamped (they are for using, not reselling - which also stops bread arbitrage). + Unstackable gear could never enter a bundle, which was the actual bug. + Retest: buy a rod with no chest boat - it lands in your pack. + - [ ] Non-stacking gear (rod, sword, bed) offers only x1; stackables also offer x16. + - [ ] Buying an expander without a chest boat now says "buy one from the + shipwright first" instead of a vague hold message. +- [ ] **Trade sounds** (the dialog blurs and covers chat, so outcomes are audible): + every successful buy/sell/expander purchase plays a bright pling; every refusal + (cannot afford, no hold space, unstamped goods, expander cap, no chest boat) + plays a dull anvil thud. +- [ ] **Wild islets**: row between trading islands through fresh ocean — small + unnamed islands (~140 across) in varied vanilla biomes, no dock/market/name + announcement. You can break/place/farm/sleep there and in open ocean + (protection flipped outside named islands). Trading islands remain protected. +- [ ] **/tw restart**: confirmation, then balance resets to starting, fresh kit at + spawn, chart KEPT; counter decrements (3 by default); refuses at 0. + +### Stage 4h — the Shipwright (boat acquisition) +- [ ] Every market's main menu has a "Shipwright" page: Oak Boat ($20-ish), Oak Chest + Boat ($60-ish), and the Cargo Expander ladder (moved here from the main menu). +- [ ] Buying a hull delivers the (stamped) boat item to your INVENTORY — place it at + the dock and ride off. Expanders still require a chest boat (hold delivery). +- [ ] Boats are NOT valid warp fuel (they are hulls, not firewood). +- [ ] Wild islets have trees: chop, plank, craft boat + chest → chest boat, the frugal + path. Bought hulls cost more than crafted ones by design. +- [ ] Boatless + broke: wild-islet timber or /tw restart are the exits. + +### Stage 4i — command permissions +- [ ] As a NON-OP player (test account, no LuckPerms grants): /tw, spawn, chart, + starchart, restart, info, settings, language all work. +- [ ] Non-op canNOT use /tw warp or /tw trade (op-only shortcuts) — but the intended + paths still work for them: rowing to an island border offers the warp dialog, + and right-clicking a plaza trader opens the market. +- [ ] Non-op cannot use /twadmin or its subcommands. + +### Stage 4j — protected spawn island (updated: visitor allowances) +- [ ] Console logs "Registered the spawn island (protection 100)" on first enable. +- [ ] As NON-OP: breaking/placing blocks on the spawn islet is blocked; wild islets + and open ocean remain free-build. +- [ ] /twadmin info at spawn shows an unowned island named "Spawn", protection 100, + range 200 (small ranges are legal now — Stranger Realms overrides). +- [ ] Restart: BentoBox loads cleanly (no "island distance mismatch" panic), spawn + island persists as spawn. +- [ ] As NON-OP on the spawn island: placing/riding a BOAT works, hitting a monster + works, using a CRAFTING TABLE works — but block break/place is still blocked. +- [ ] No hostile mobs spawn on the spawn island; primed TNT there breaks no blocks + (flags re-asserted every enable, so the existing spawn island picks these up). +- [ ] Trading islands still register/announce correctly (arbitrary centers untouched + by the isFixIslandCenter override). + +### Stage 4k — teleport friction (anti-escape) +Set `commands.delay.time: 3` in `plugins/BentoBox/config.yml` and test as a NON-OP +(ops and `tradewinds.mod.bypassdelays` holders always bypass delays): +- [ ] `/tw spawn` says "stand still for 3 seconds"; moving cancels the teleport. +- [ ] Set `travel.warp.stand-still-seconds: 3` in the TradeWinds config: engaging a + warp says "Hold your course..."; standing still completes the jump. +- [ ] Moving during the countdown aborts the warp and salvages the fuel back into the + hold as charcoal (dropped at your feet if the hold is full). +- [ ] With `stand-still-seconds: 0` (default) warps remain instant. + +## Stage 5 — Risk at sea + +**Half one: the interstice (warp risk).** Set `travel.warp.failure-chance: 1.0` +temporarily to force it. +- [ ] Warping drops you into `tradewinds_world_nether` partway along the route, in + your boat, with 1-3 Ghasts inbound and a "the warp collapses!" message. +- [ ] The re-engage dialog appears within ~20s (and repeats): clicking it delivers + you to the ORIGINAL destination for FREE — no second fuel charge. +- [ ] Stranding is impossible: relog in the interstice (losing the pending + destination) — the dialog still offers a free jump to your nearest charted + island. +- [ ] Ghast fireballs can be batted back; the fight is survivable in a boat. +- [ ] Restore `failure-chance: 0.05` afterwards. + +**Half two: sea encounters (rowing risk).** +- [ ] Row in open water far from islands: within a few rolls (45s each) mobs appear + ~28 blocks AHEAD of you — visible, so fleeing is a real choice. +- [ ] Day vs night differ: guardians by day, trident-throwing drowned at night. +- [ ] Lawless/anarchic water adds worse things: an ELDER GUARDIAN in deep water + (flee — it is meant to be unwinnable for a lone trader), phantoms at night, + pillager PIRATE CREWS in their own boat, and the sea witch. + - ~~The zombie nautilus just swam away~~ FIXED: it is a tameable MOUNT, not a + monster (AbstractNautilus extends Tameable/Vehicle), so it was never going to + attack. Replaced with the elder guardian; a test now asserts every encounter + mob implements Enemy. + - Water mobs also used to spawn ABOVE the waterline, where they flop instead of + hunting. They now spawn 3 blocks under, phantoms 14 above, boats on the + surface. + - ~~The witch adrift did nothing and was easy to kill~~ FIXED: mobs riding a + boat cannot run their attack goals, and the target was set once at spawn. + An aggression pass now re-asserts targets every 2s, and boated crews + ABANDON SHIP as soon as they sight you (~30 blocks, wider than the spawn + distance) — a mob that cannot reposition lobs its potions over your head. + The abandoned boat stays behind: salvage it if you want. +- [ ] Safe island waters are quiet (2% base, further reduced near the dock); + encounters intensify with distance from any island. +- [ ] Killing encounter mobs sometimes drops booty (nautilus shells, tridents, + ingots...) which IS customs-stamped — check it can be sold at a market. +- [ ] Only one encounter at a time per player; mobs despawn naturally when far away. +- [ ] `encounters.enabled: false` turns the whole system off. + +## Stage 5b — Spawn is a real trading island + +**World note**: the origin now hosts a full trading island instead of the bare islet. +Stop the server, delete the four origin region files (`tradewinds_world/region/r.0.0.mca`, +`r.-1.0.mca`, `r.0.-1.mca`, `r.-1.-1.mca`) AND the old spawn island from the TradeWinds +database (or use `/twadmin delete` on it), then restart. + +- [ ] Console logs "Designated Spawn (FISHING) as the spawn island" on first enable. +- [ ] `/tw` puts you ON the market plaza of an island named **Spawn** — villagers, + stalls, landmark, the lot. Trading is available from second one. +- [ ] Walk down the dock, place your boat, and sail: the border warp prompt appears at + the island edge like any other island. The navigation boss bar shows "Spawn". +- [ ] The island is SAFE band: no hostile spawns, no PvP, villagers protected — plus + the harbor allowances (boats, workbenches, hitting monsters) for visitors. +- [ ] Non-op still cannot break blocks there; TNT breaks nothing. +- [ ] Die without a bed: respawn on the Spawn plaza. +- [ ] `galaxy.spawn-island-type` picks the economy (FISHING default, RANDOM allowed) — + needs a world regen to change. +- [ ] Admin control works as normal BentoBox: `/twadmin setname`, `/twadmin + setspawnpoint`, `/twadmin settings` on the spawn island. +- [ ] No other island is closer than 2500 blocks to spawn (nothing crowds the port). + +### Stage 5c — island flags (three bugs: load order, setFlag, port allowances) +- [ ] As NON-OP at ANY trading island (not just spawn): moor, exit and RE-BOARD your + boat after shopping; hit a hostile mob; use a crafting table; open stall gates. + Run `/twadmin reflag` once to push these onto islands registered earlier. +- [ ] After restart, the island JSON in `plugins/BentoBox/database/Island/` shows + `"spawn": true`, `"name": "Spawn"`, and flags including BOAT/CRAFTING/ + HURT_MONSTERS at 0. +- [ ] Only ONE island file exists at the origin (no duplicate created per restart). +- [ ] Non-op at spawn: **place and ride a boat**, use a crafting table, hit a monster. + (Rank flags were silently dropped before — see PROGRESS.md.) +- [ ] Non-op anywhere: drop and pick up items on trading islands. +- [ ] `/tw settings` at spawn shows BOAT/CRAFTING/HURT_MONSTERS at Visitor rank. +- [ ] An island registered by an older build (no name, no flags) is adopted on + restart: it gains its name, band flags, and — for spawn — protection 400 / + range 1000. + +### Stage 5d — the carried hold, pouches and white expanders +- [ ] The Shipwright sells **Trading Pouches** ($250) up to the 3-pouch limit, then + refuses ("cannot carry any more"). A pouch is deliberately poorer value per item + than an expander — the early rung, not the destination. +- [ ] Cargo expanders are **white** shulker boxes named "Cargo Expander" (vanilla + purple ones from earlier builds still work as cargo). +- [ ] Buy a Cargo Expander: it lands in your PACK (no chest boat needed). Right-click + it anywhere to open and fill it - that is your hold. + - ~~Fail - right clicking a cargo expander did not open its inventory.~~ + - FIXED (retest): two gestures now open it. + 1. **Inventory screen** (the natural one, new): open your inventory, right-click + the expander where it lies (empty cursor) - the expander view opens. Left + click and click-with-item-on-cursor stay vanilla. An expander inside a chest + or another container does NOT open this way. + 2. **In hand**: hold it and right-click - at sea aiming at the sky AND ashore + aiming at the ground (air clicks used to arrive "already cancelled" and were + skipped). The shulker box must never be placed as a block either way. + Fill it, close it, reopen: contents persist. Sell from it at a market. +- [ ] Goods inside a carried expander are sellable, and count as fuel for warping. +- [ ] Hold space shown in the market reflects pouches + expanders (+ chest boat when + riding one). +- [ ] Riding a chest boat still adds its 27 slots to the hold; at a port you can + sneak-right-click your own chest boat to open it (CHEST flag now visitor rank). +- [ ] Loose items in your inventory are still NOT sellable (they must be in a pouch, + an expander, or the boat). + +### Stage 5e — respawn on the plaza (playtest fix) +- [ ] Die without a bed: you respawn ON the spawn island's market plaza, standing on + flat ground beside the stalls — not in the treetops at the island centre, and + not inside the bell. +- [ ] `/tw spawn` puts you in the same place. +- [ ] After `/twadmin setspawnpoint` somewhere else on the island, respawns and + `/tw spawn` both honour the new point. + +### Stage 5f — findable islets, openable expanders, charting range +- [ ] Row (or teleport) into open ocean far from any island: wild islets are now + within ~500-1700 blocks of anywhere — you should meet one within a short row. + They keep clear of trading islands. +- [ ] **Right-click a Cargo Expander in hand** to open it — this is the only way + (Java cannot open shulker boxes from the inventory). Put items in, close, and + they are still there when you reopen; they count as hold for trading and fuel. +- [ ] An expander cannot be placed as a block, and cannot be stowed inside another. +- [ ] Rowing within ~1200 blocks of an uncharted island charts it ("Charted !" + on the action bar) and it then appears in /tw chart, the warp dialog and the + star chart. `chart.sighting-range` tunes this. + +### Stage 5g — recovery: cheap first pouch and the harbourmaster's charity +- [ ] Trading Pouches cost a flat $50 each, up to the 3-pouch cap. (Escalating prices + were dropped: dropping a pouch before buying reset the count. The cap — not the + price — is what makes expanders worth buying.) +- [ ] **Soft-lock check**: with no pouch, no expander and under $50, the Shipwright + shows "Harbourmaster's charity": claim it for a free pouch (plus a boat if you + have none). You can immediately buy goods and trade again. +- [ ] The charity refuses if you are NOT destitute ("You'll manage"), and again + within 15 minutes of a claim ("Come back in N minutes"). +- [ ] Charity goods cannot be sold (unstamped), so repeat claims yield no money. + +### Stage 5h — the port scan (free chart at a trading island) +- [ ] Stand on any trading island and run `/tw chart` (or board a boat there): "You + copy the harbour charts: N islands added to yours." +- [ ] Those islands immediately appear in `/tw chart`, the hologram compass, the star + chart, and — the point of it — the warp dialog, so docking anywhere leaves you + with a full menu of onward routes. +- [ ] Running it again at the same port adds nothing (they are already charted). +- [ ] Opening the chart at sea (not at an island) does NOT scan — discovery still + requires making landfall. +- [ ] `chart.port-scan: 0` disables the free scan. + +### Stage 5i — varied ocean biomes +**Needs fresh chunks** (biomes are baked at generation). +- [ ] Sail a long way through open water: the sea changes through frozen, cold, + ordinary, lukewarm and warm ocean — water colour, fog and fish change with it. +- [ ] Transitions are gradual: you never find warm water directly against frozen. + (A test walks 80,000 blocks asserting the temperature never skips a step.) +- [ ] The same seed gives the same seas; a different seed gives different ones. +- [ ] `world.vary-ocean-biomes: false` returns the world to a single ocean biome. + +### Stage 5j — the sea itself: seabed, structures, islets +**Needs a fresh world** — terrain and biomes are baked at generation, so delete +`world/dimensions/minecraft/tradewinds_world` (and the nether one) before testing. +The test server's config has already been updated to the new defaults. + +**Islets — the "I couldn't find a single one" fix** +- [ ] Sail in any direction from anywhere: you meet an islet within a few hundred + blocks, not a few thousand. (Measured on the live seed: mean 544 blocks to the + nearest, worst case 1,376.) +- [ ] Islets vary — sandbars barely clearing the water through to islands 140 blocks + across, and small ones are lower as well as narrower. +- [ ] **Coastlines are not circles.** Islets and trading islands alike have bays, + headlands and lobes, and their land is hills and hollows rather than a smooth + dome with terraced contour rings. (`galaxy.coast-roughness` and + `galaxy.island-hilliness`; set both to 0 to see the old coin shape.) +- [ ] The shallow water around an island is not a perfectly circular pale ring — + the sea floor keeps rolling across the shelf. +- [ ] Islands are still whole: no island has broken up into a scatter of fragments, + and every trading island's quay still runs unbroken out into open water. +- [ ] Every islet has a sandy shoreline ring with beach biome (snowy beach in cold + seas), and grass or its own biome inland. +- [ ] Roughly 1 islet in 16 is **mushroom fields**: mycelium, mooshrooms, red and + brown mushrooms, and no hostile mobs spawning on it. + +**The sea floor** +- [ ] Dive in open water in several places: the floor is genuinely different depths — + sunlit banks around 14 blocks down, dark basins over 45. +- [ ] Find a **rift**: a narrow canyon wandering across the floor, dropping sharply + well below the surrounding seabed. +- [ ] Find a **seamount**: an underwater peak rising off a deep plain — and confirm it + never breaks the surface. Nothing but islands and islets should be land. +- [ ] The floor is patchy, not static: banks of sand, beds of gravel, clay pans, and + bare stone/tuff in the deeps — not alternating sand/sandstone every block. +- [ ] Water colour changes with depth as well as temperature: deep basins read as + deep ocean biomes (`F3` shows `deep_ocean`, `deep_cold_ocean`, ...). + +**What vanilla now puts there** (`make-structures: true`) +- [ ] **Shipwrecks** on the sea floor, and beached ones on islet shores. +- [ ] **Ocean ruins** — warm (sandstone) in warm/lukewarm water, cold (stone) in + colder water. They should match the water they are in. +- [ ] **Ocean monuments** in the deep basins, with guardians and elder guardians. + (Monuments only generate in deep ocean biomes, so this is the check that the + depth-to-biome mapping is working at all.) +- [ ] **Buried treasure** on islet beaches — a treasure map from a shipwreck or ruin + should lead somewhere real. +- [ ] **Trial chambers** in the rock under the sea floor: dig or cave down and find one. +- [ ] **No structure of any kind on a trading island** — no monument, ruin or village + through a plaza, dock or market. Fly around several islands and confirm. + (`world.keep-structures-off-islands: true`.) + +**Caves** (`make-caves: true`) +- [ ] **No voids in the sea floor.** Swim over a lot of open water: the floor is + unbroken. No dry craters, no open gashes, no ravines cut through to the water. + (Vanilla's carvers have no idea there is an ocean overhead and generated chunks + get no block updates, so nothing ever flows in to fill what they cut. The + generator seals a 5-block crust back over them in `generateCaves`, the Poseidon + fix — narrowed so the caves themselves survive.) +- [ ] Dig down through the sea floor anywhere promising: the caves are still there + underneath, and flood when you break into them. That is the point — sealed, not + filled in. +- [ ] Cave mouths in an island's flank *above* the waterline are left alone. +- [ ] Caves do not break into a market plaza or dock from below. + +**Regression** +- [ ] Trading islands still sit properly in the water, docks still run unbroken from + plaza to pier end, and the spawn island is unchanged — an island over a deep + basin must look the same as one over a shelf. + +## Stage 6a — The law: port flags and reputation + +**Flag policy changed for ALL trading islands.** Existing islands are re-flagged on +chunk load, but run `/twadmin reflag` after starting to be sure, then test as a +**non-op player** — every flag bug so far was invisible to an operator. + +### 6a-i — Port flags (the audit) +A trading island is now a public market: every protection flag is allowed at visitor +rank *except* an explicit deny list. +- [ ] **Things that must work** as a non-op visitor at any trading island: board and + leave a boat, open a chest boat and a cargo expander, use a crafting table, + anvil, furnace, grindstone, smithing table, loom, stonecutter, cartography + table and brewing stand, open doors/gates/trapdoors, press buttons, ring the + plaza bell, sleep in a bed, drop and pick up items, hit hostile mobs, fish, + throw an ender pearl. +- [ ] **Things that must NOT work**: break or place any block, use flint and steel, + prime TNT, empty a bucket, break an item frame or armour stand, edit a sign. +- [ ] **Things that must NOT work, because they would mint money outside the market** + (spec 5.0 — money enters the game only through trade margins): harvest or + trample the island's crops, open its barrels/containers/hoppers, take honey + from a hive, shear/milk/breed/kill its livestock. +- [ ] **Right-clicking a villager must NOT open a vanilla trade screen** — trading + goes through the market dialog and the hold only (principle 1). +- [ ] Hurting villagers is still refused on SAFE islands and allowed on rougher ones. +- [ ] `bands.port-denied-flags` / `port-allowed-flags` in config override both ways + (add `HARVEST` to allowed, reflag, and confirm you can now harvest). + +### 6a-ii — Reputation +- [ ] The navigation boss bar shows your standing, and it changes as you earn one. +- [ ] Punch a villager on a non-SAFE island: "The market saw that. Reputation -5." +- [ ] Kill a villager: bigger loss, and the message mentions money on your head. +- [ ] Kill an iron golem: reputation loss for killing police. +- [ ] Crossing a band boundary shows a title with your new standing. +- [ ] Commit enough crime to reach **Wanted** (−200 by default; one murder plus + change, or eight villager kills). Then **Fugitive** at −500. +- [ ] Reputation decays back toward zero by 1 point per 15 minutes of play — and + **only while online and in a TradeWinds world**. Log out for a while and + confirm your reputation is exactly where you left it. +- [ ] `/tw fine` out at sea is refused ("paid at a trading island"). At a port it + quotes a price, asks for confirmation, charges you, and returns you to Clean — + **never above it**. Check the balance actually moved. +- [ ] With nothing to answer for, `/tw fine` says so and charges nothing. +- [ ] As a Fugitive, `/tw fine` at a **SAFE** island is refused; at a rougher port it + works. (Crime pays, into danger — and buying your way back costs you the same.) +- [ ] `crime.enabled: false` stops all of it: no penalties, no messages, standing + reads Clean for everyone. + +### 6a-iii — PvP and bounties (partial; police land in 6c) +- [ ] Killing a player who is **not** a lawful target costs the killer 100 reputation + ("Murder"). +- [ ] But if the victim hit you first (within 30s), there is **no** penalty — the + anti-bait guard. Verify both ways round; a wanted player being able to farm + reputation loss off innocents by attacking them would be a weapon. +- [ ] Killing a **Wanted or Fugitive** player pays their bounty to the killer with no + penalty, and the bounty is cleared — kill them again and it pays **nothing**. + +## Stage 6b — Customs and contraband + +Contraband is **sugar** by default (`economy.unstamped-sellables`) — the one thing a +player can make and still sell, and the deliberate hole in the two-economies rule. +Grow some, or `/give` yourself sugar, and put it in your **hold** (a pouch, expander +or chest boat — loose pockets are not cargo and are not scanned). + +**Use `/twadmin customs`** — it prints the whole customs state where you stand +(contraband in your hold, your standing, this island's band, your effective scan +chance, patrol size, whether a chase is running). Scans are probabilistic and +cooldowns are invisible, so guessing from behaviour alone is a trap. + +### 6b-0 — Fixes from the first playtest +- [ ] The customs alert reads "CUSTOMS" with "Patrol dispatched from " beneath + it — **no line-feed glyph**. (A `` inside a title half is parsed into a + real newline character, which a title cannot render.) +- [ ] A chat line follows the title and stays in the log, so a blink does not lose it. +- [ ] **Log in inside a port with contraband: nothing happens.** Logging in is not an + entry. Row out and back in and the scan fires normally. +- [ ] **Warp away mid-chase, then into another island's space: the new island scans + you.** (Previously the old chase stayed live and blocked every later scan, so + customs silently stopped working for the rest of the session.) +- [ ] Standing on a plaza with contraband when a scan fires: there is no water for a + patrol to launch from, so customs simply seize the cargo and fine you, with a + message saying so — rather than spawning guardians that flop about on land. + +### 6b-0b — Fixes from the second playtest +- [ ] **Contraband actually pays.** Sell sugar at a FRONTIER/LAWLESS/ANARCHIC port: + roughly $12-14 an item, not $1. It pays more the rougher the port. + (`illegal-trade.contraband-price-multiplier`, default 8.0 — the main balance + lever, and the one price in the game not funded by a purchase elsewhere.) +- [ ] The sell page **no longer quotes a price for contraband at a port that will + refuse it** — sugar simply is not listed at SAFE/POLICED islands, instead of + being offered for a dollar and then declined at the counter. This is what made + `/twadmin customs` look like it was lying: it said "buys contraband: false" + while the dialog was showing an offer. +- [ ] **No trader stocks contraband.** Check the buy page at an AGRICULTURAL island: + no sugar. (If they sold it you could buy at the honest price and sell at the + black-market premium with no farming and no risk.) +- [ ] **A patrol never spawns on top of you.** It surfaces at least ~12 blocks away, + well beyond the 4-block arrest radius, so there is always a chase to run. + Previously the water search fell back to the player's own position: patrols + materialised alongside the boat, opened fire, and the arrest registered before + the warning had been read — "I got instantly hurt and lost the sugar". +- [ ] Note that warp arrival damage (`travel.warp.damage`, default 2.0) is separate + and intended — warping hurts. Do not confuse it with a patrol. + +### 6b-i — The scan +- [ ] Row or warp into a **SAFE** island's protection range carrying no contraband: + "Customs board you, look through the hold, and wave you on." (Set + `announce-clean-scans: false` to silence it.) +- [ ] Do it again immediately — no second scan, the per-island cooldown holds + (10 min default). This is what stops re-entry dice-rolling. +- [ ] Enter a **SAFE** island with sugar in the hold: title card "CUSTOMS — Patrol + dispatched", and drowned/guardians surface **between you and the island**. +- [ ] Warping in triggers the scan exactly like rowing in. +- [ ] Enter an **ANARCHIC** island with contraband: no scan at all, ever. +- [ ] Being **Upstanding** noticeably reduces how often you are searched; being an + Offender or worse increases it. + +### 6b-ii — The chase, and its three ways out +- [ ] **Jettison**: drop the sugar mid-chase. "Your cargo goes over the side" — the + patrol despawns, no fine, no reputation loss. The dropped items float and + **anyone else can pick them up** (this is where piracy comes from). +- [ ] **Run**: get more than 400 blocks beyond the protection range. "Open water..." + — you keep the cargo, no penalty. +- [ ] **Caught**: let a patrol unit reach you (within 4 blocks) or let one hit you. + All contraband is seized, a fine is taken, and reputation drops by 30. + Check the balance actually moved and the hold is actually empty of sugar. +- [ ] Fight the patrol instead: killing a unit costs reputation (KILL_POLICE)... +- [ ] ...and **police drop nothing at all** — no items, no XP. Kill several and + confirm. (Loot-bearing police would make a criminal record an iron farm.) +- [ ] The chase ends by itself after 2 minutes if nothing else resolves it, and the + patrol despawns. Log out mid-chase and the patrol is cleaned up too. + +### 6b-iii — The flee flag +- [ ] After escaping a chase, go back to **that same island** within 20 minutes: + "[name] remembers you... the patrol is already coming." No scan roll — the + patrol launches immediately. +- [ ] A *different* island still rolls normally. The flag is per-island. +- [ ] The flag expires after 20 minutes. + +### 6b-iv — Selling contraband +- [ ] Try to sell sugar at a **SAFE** or **POLICED** island: refused, with the trader + telling you to try a rougher port, and the dull "no" sound. +- [ ] Sell it at a **FRONTIER**, **LAWLESS** or **ANARCHIC** island: it sells, with no + customs stamp needed. This is the only way to turn farmed goods into money. +- [ ] `illegal-trade.safest-contraband-buyer: SAFE` makes every port buy it; + `ANARCHIC` makes it almost unsellable. +- [ ] `illegal-trade.enabled: false` removes all of it: no scans, no patrols, and + sugar becomes an ordinary unsellable homemade good. + +## Stage 6c — Police, wanted response, bounties + +Get yourself **Wanted** (−200: one murder, or ~8 villager kills) with +`/twadmin customs` to check your standing, then sail into a policed island's space. + +### 6c-i — The standing response +- [ ] Enter a **SAFE** or **POLICED** island's protection range while Wanted: "…has + patrols out for you", and units appear — guardians, drowned and a **phantom** + if you are afloat; **iron golems** and a phantom if you are ashore. +- [ ] The phantom is the only thing that can follow a boat. Row away and confirm it + keeps up while the swimmers fall behind. That is the point of the roster. +- [ ] Phantoms **do not burn at dawn**. Trigger a response in daylight and watch. +- [ ] A **FUGITIVE** draws a larger response than merely Wanted. +- [ ] **LAWLESS** sends one unit; **ANARCHIC** sends nobody at all. Confirm you can + sit in anarchic water as a fugitive completely unmolested — that is the whole + reason to go out there. +- [ ] Police ignore innocent bystanders: stand a second (clean) player next to the + patrol and confirm it stays on the wanted one. + +### 6c-ii — Break-off and no leaks +- [ ] Cross the border and keep going: past ~400 blocks beyond the protection range + the patrol breaks off ("The patrol turns back at the border") and the units + are **removed**, not left drifting. +- [ ] Pay your fine mid-pursuit (`/tw fine`): the patrol stands down immediately. +- [ ] Log out mid-pursuit, log back in: no orphaned police anywhere. +- [ ] Fly a long way off and come back: no accumulation of stray guardians or + phantoms. (`/twadmin customs` shows whether a chase is live.) +- [ ] Kill police: still **no drops, no XP**, whatever the unit type. + +### 6c-iii — PvP override and bounties +- [ ] A Wanted player can be attacked **even on a SAFE island** where the PvP flag is + off. A clean player in the same place still cannot be attacked. (Without this a + wanted player could moor in high security and be untouchable.) +- [ ] Killing them pays their bounty to the killer, with no reputation penalty. +- [ ] The bounty is cleared by the payout — kill them again and it pays **nothing**. +- [ ] Their name shows the bounty beside it while it is above zero, and the tag + disappears once paid out. (`crime.bounty-nameplate: false` removes it.) +- [ ] With PlaceholderAPI installed, `%tradewinds_bounty%`, `%tradewinds_standing%`, + `%tradewinds_reputation%` and `%tradewinds_wanted%` all resolve. Servers using + TAB should turn the nameplate off and use these instead. + +### 6c-iv — Fugitive trade bar +- [ ] As a **Fugitive**, the market at a SAFE or POLICED island refuses to open at + all ("We know what you are"). +- [ ] At FRONTIER and beyond it opens normally. Merely **Wanted** is not barred — + only Fugitive. (`crime.safest-fugitive-trader`.) + +## Interstice: a failed warp must not be an execution + +Playtest: a new player's first warp failed, ghasts opened fire instantly, and they +died having lost everything before they could read the dialog offering the free way +out. Set `travel.warp.failure-chance: 1.0` temporarily to test this repeatedly. + +- [ ] Fail a warp several times. Roughly **40% of the time nothing comes at all** — + dark water and silence (`interstice.ghast-chance`). +- [ ] When ghasts do come, they appear **~90+ blocks away** and are **not already + hunting you**. You can see them, and decide. (They spawn beyond their own + 64-block detection range and are deliberately not given a target.) +- [ ] For **20 seconds after arrival** nothing can target or damage you + (`interstice.grace-seconds`) — long enough to read the dialog and click + re-engage. Confirm a fireball already in the air does not kill you either. +- [ ] Re-engaging is still free, and still returns you to the original destination. +- [ ] Stay past the grace and approach them: it becomes a real fight. The grace + covers the arrival, not the visit. +- [ ] A brand new player with a starter kit can survive a failed first warp without + losing their boat and cargo. That is the actual bar here. + +## Warp arrivals must never land inside anything + +- [ ] Warp repeatedly to the same island from the same origin. You always arrive on + **open water**, never inside the quay decking. (The arrival ring is 130 blocks + from the centre; the quay reaches 136 with its deck at exactly arrival height, + so ~0.9% of bearings used to suffocate you on arrival.) +- [ ] Arrivals still land close to the intended spot - the search takes the nearest + open water, so the island should still be in view. +- [ ] `/twadmin warpfail ` rigs that player's next warp to fail. Run it + again on the same player to clear it. Check the console/chat feedback both ways. +- [ ] After a rigged failure, the flag is gone: the next warp behaves normally. +- [ ] Interstice arrivals also land on open water, not inside the interstice floor. + +## Low fuel warning, and reading the boss bar + +- [ ] Stand at a port with less fuel than the cheapest charted warp costs: a **LOW + FUEL** action bar appears and **repeats** every 8 seconds while you are there. +- [ ] A **chat line** also arrives, once per port visit, saying how much more fuel + you need. It stays in the chat log — this is the one a child will still find + after looking away. +- [ ] Open the market: the body says LOW FUEL, and the button that actually sells + fuel is relabelled **"BUY FUEL HERE"** — the Outfitter normally, or the Buy + Goods button at islands whose own catalog stocks fuel. +- [ ] Buy fuel: all three warnings stop. +- [ ] With nothing charted to warp to, there is **no** warning — being unable to + warp is not a fuel problem then. +- [ ] Out at sea (not at a port), no warning. It is only raised where it is fixable. +- [ ] `travel.fuel-warning.margin: 1.5` warns while you can still just about leave; + `enabled: false` removes all of it including the dialog highlight. + +- [ ] The navigation boss bar now reads **name | band | standing | dock distance**, + with the band coloured — blue Safe, green Policed, yellow Frontier, red + Lawless, bold dark-red ANARCHIC. Sail into each and confirm you can tell at a + glance what kind of water you are in. +- [ ] The same coloured band appears on the chart holograms and the market subtitle. + +## /tw go - the door into the ocean, and nothing more + +`/tw spawn` was a free warp back to a market once spawn became a working trading +post. But deleting it locked new players out entirely, so it is now a door with +two rules. + +- [ ] **From another world** (or on first ever join): `/tw` or `/tw go` puts you + into the ocean. A brand new player arrives at the spawn port with a starter kit. +- [ ] **A returning player arrives where they LEFT the ocean**, not at spawn. Sail + a long way out, `/mv tp world` (or `/acid`), then `/tw` - you should be back + in the same stretch of water, not at a market. +- [ ] **Already at sea**: `/tw go` refuses ("You are already at sea..."). This is the + exploit that had to close - there is no commanding your way to a market. +- [ ] Log out at sea, log back in: you are where you left off, and `/tw go` still + refuses. +- [ ] Dying still respawns you at the spawn plaza (not a cheat - you lost your cargo + to get there). +- [ ] `/tw restart` still returns a destitute player to spawn with a fresh kit, + capped by `player.max-restarts`. +- [ ] The old `/tw spawn` label still works as an alias, and does the same thing. + +## Dock marker on the hologram chart + +- [ ] Inside an island's waters, `/tw chart` (or boarding a boat) now raises a + **DOCK** hologram alongside the island names, pointing at that island's pier + with its distance. Row toward it and the number falls. +- [ ] It points at the **pier**, not the island centre - check on an island whose + dock faces away from you. +- [ ] It hangs **below** the island name markers and has a warmer background, so it + never collides with their stack. +- [ ] Out in open ocean, away from any island, there is no dock marker - just the + island names. +- [ ] With an empty chart but inside an island's waters, the dock marker still + appears on its own. (Previously an empty chart showed nothing at all.) +- [ ] The label is translatable: `tradewinds.hologram.dock`. + +## Star chart: fuel range, reachability, readable names + +- [ ] Hold the Star Chart: island names are **white** and clearly readable against + the blue ocean (they were a mid grey that all but vanished). +- [ ] A **dashed ring** is drawn around you at the limit of your fuel range. Burn or + buy fuel and it shrinks/grows on the next redraw (about a second). +- [ ] With no fuel there is no ring; with a huge amount the ring is off the chart and + is simply not drawn, rather than being clamped to the edge and lying about it. +- [ ] Islands inside the ring are the ones you can afford to warp to - cross-check + against `/tw chart list`. +- [ ] `/tw chart list` shows **Fuel aboard: N units** and marks every entry with its + fuel cost: green and plain when reachable, greyed with "(not enough fuel)" when + it is not. +- [ ] Standing at a port, the quoted cost matches the warp dialog exactly (it uses + the same route price, including any lane overrides in + `travel.warp.edge-overrides`). +- [ ] Adrift between islands the cost is an estimate from your position - close + enough to plan by. It should still fall as you approach a destination. + +## Dialogs close under attack; no warping out of a fight + +- [ ] Open any dialog (warp, market, shipwright) and take damage: the dialog + **closes**. A modal screen hides the boat, the water and whatever is shooting. +- [ ] Any damage does it, not just mobs - drown while reading a shop menu. +- [ ] With a hostile mob within 12 blocks, the warp dialog **refuses to open** and an + action bar says "You cannot warp while enemies are close." Same idea as a bed + refusing to let you sleep. +- [ ] Kill or outrun the mob and the dialog opens normally. +- [ ] Let a mob arrive **while the dialog is already open**, then click a + destination: the warp is refused, and no fuel is taken. (Checked again at the + moment of engaging, because that is when the fuel is spent.) +- [ ] With `travel.warp.stand-still-seconds` above 0, a mob arriving during the + countdown aborts the warp and **refunds** the fuel. +- [ ] **A customs chase cannot be escaped by warping** - the patrol counts as + enemies. Run, fight or jettison, as designed. +- [ ] **The interstice re-engage still works with ghasts nearby.** This is the one + exemption and it matters: it is the way out of somewhere dangerous, and gating + it would strand players. +- [ ] `travel.warp.enemy-radius: 0` disables the gate (dialogs still close on damage). + +## Star Chart is ephemeral + +- [x] `/tw starchart` puts the chart straight **into your hand** (a free hotbar slot, + selected for you) rather than somewhere you have to hunt for. +- [x] **Switch to another hotbar slot: the chart is gone.** Run the command again to + consult it - it is an instrument, not cargo. +- [x] Run `/tw starchart` twice: you end up with **one** chart, not two. +- [x] Try to drop it: it vanishes rather than bobbing in the sea. +- [ ] Die holding it: it is **not** in your death drops, and nobody can salvage one. +- [ ] Log out holding it, log back in: gone, and the command still works. + - Log back in - it is in my hand. This is OK actually. +- [x] Sanity check the point of all this: after a dozen `/tw starchart` calls there + are no stray maps in your inventory, on the ground, or in your hold. +- [x] With a completely full hotbar it is added anywhere it fits rather than refused. + - True, but if your inventory is completely full, there is no error to say that your inventory is full so it cannot be given. + +## The interstice: lit, lidded, and ghasts you can actually see + +**Needs fresh interstice chunks** - delete `world/dimensions/minecraft/tradewinds_world_nether`. +Use `/twadmin warpfail ` then `/tw warp` to get there on demand. + +- [ ] **The ghasts are visible and close enough to identify** - about 28 blocks, in + a tight band (the spread used to add up to 30 blocks on top of the setting, + which quietly undid it: a base of 44 arrived as far out as 74). +- [ ] **They come for you when the grace runs out.** They arrive without a target so + landing is not an ambush, but after 20 seconds anything nearby acquires you. + Previously they never re-aimed at all, so they drifted and nothing happened. +- [ ] The re-engage dialog has a **"Stay a while"** exit button. Declining costs + nothing - the offer comes round again and the fuel is already spent. +- [ ] Some failures still bring nothing at all (`ghast-chance: 0.6`). +- [ ] The 20-second arrival grace still holds - nothing targets or hurts you while + you read the dialog. +- [ ] **There is a ceiling** about 48 blocks above the sea: netherrack with bedrock + on top. Fly up and confirm you cannot leave. +- [ ] **Burning braziers** - netherrack outcrops rising out of the water with fire on + top - appear across the sea, roughly one chunk in six. They light the place and + give you something to steer by. +- [ ] The fires stay lit (netherrack burns forever) and do not spread - there is + nothing out there to catch. +- [ ] Some braziers have glowstone at their base, visible from underwater. +- [ ] Overall: the interstice reads as somewhere hostile you are trapped in, rather + than an unfinished black void. + +## Contraband: pockets count, and the patrol gives you room + +- [ ] **Sugar in your normal inventory slots is scanned too**, not just the hold. + Try the obvious dodge: move the sugar from a pouch into your pockets, cross a + border, stow it again. You are still caught. (Hold-only scanning made that a + free pass.) +- [ ] Being caught seizes contraband from **both** the hold and the pockets - check + the item count in the message against everything you were carrying. +- [ ] A patrol now surfaces about **45 blocks** away, comfortably beyond a guardian's + 15-block laser, so there is a real moment to turn and run. They should not be + hitting you before the warning has finished printing. +- [ ] Warp into an island: you arrive **clear of the shore**, not on the beach or in + a bay. (About 4% of approach bearings used to land on or beside land, because a + warped coastline can reach the arrival ring; arrivals now step outward along + the approach, never sideways or inward.) + +## Customs patrols launch from the dock + +Watch the console - every dispatch now logs the player, the pier, the launch +point, and each unit's distance. + +- [ ] Trigger a customs scan. The patrol appears **at the island's pier**, not + beside your boat, and swims out to you. The console line shows the launch + point and how far it is from you. +- [ ] The warp arrival is logged too: coordinates and distance from the island + centre. Cross-check it against where the patrol starts. +- [ ] There is a **phantom** in the patrol - the only unit that can catch a boat. + The guardians and drowned should fall behind if you run. +- [ ] Running works: get clear of the border and the chase breaks off. +- [ ] **A second, innocent player standing nearby is never hurt by the patrol** - + not by a guardian beam, not by a trident. Try mooring next to the smuggler. +- [ ] The patrol still switches back to the smuggler if it wanders. + +## Warp arrival distance and patrol reachability + +- [ ] A warp now lands you just inside the island's **visible border** (~320 blocks + from the centre), where the warp dialog offers itself on the way out - not on + the island's underwater shelf. The nav bar and dock hologram give you the + heading. + - ~~Fail - arriving at 400 landed exactly ON the warp-offer ring, so the warp + dialog reopened the moment you arrived.~~ + - FIXED (retest): arrival default is now 320 (inside the ring), and arrival also + seeds the prompt cooldown, so even a config that lands on the ring cannot + reopen the dialog. Update `travel.warp.arrival-distance` to 320 in the live + config. Retest: warp anywhere - no dialog on arrival; row inward, trade, row + out - the dialog appears at the border crossing as before. +- [ ] A customs patrol launches from the pier if you are near it, otherwise from as + far along the way as the server will simulate (~80 blocks). Check the console + line: the launch point should never be more than that from you. +- [ ] **The patrol actually moves.** Anything beyond ~160 blocks (simulation + distance) never ticks at all, which is what "they spawned but nothing + happened" was. +- [ ] Let a chase run past 2 minutes without leaving island space: you are told the + patrol **gives up and turns for home** - not that you reached open water - and + you are NOT flagged at that port. +- [ ] Genuinely outrun a chase across the border: you still get the open-water + message and you ARE flagged. + +## Chart holograms stay above the horizon + +- [ ] In a boat in island waters, `/tw chart`: the **DOCK** marker floats clearly + above the waterline - never submerged or straddling the horizon, however far + away the pier is. +- [ ] The island-name signs stack **above** the dock marker (dock lowest, nearest + island next, further islands higher) with no overlap. + +## Islet biomes follow the sea temperature + +- [ ] Sail a frozen sea: islets there are snowy (snowy plains/taiga, ice spikes, + grove, frozen peaks) with snowy beaches; no jungle or desert in sight. +- [ ] Sail a warm sea: desert, badlands, bamboo jungle and mangrove islets. +- [ ] Temperate water: forest-family islets, including the rare **cherry grove** + and **pale garden** (pale oaks, pale moss - and creakings at night, so + approach one after dark deliberately). +- [ ] Vanilla decoration matches the biome: cherry trees on a cherry islet, pale + oaks on a pale garden islet, cacti/dead bushes on desert ones. +- [ ] Mushroom islets still roll ~6% anywhere, mycelium with no beach. +- [ ] Pre-existing explored areas keep their old islet biomes; a chunk seam through + a half-generated islet MAY show a biome change (accepted on the test server). + +## Ground truth: surfaces match the biome + +- [ ] A desert islet is SAND (sandstone beneath when you dig); badlands are red + sand over terracotta; mangrove islets are mud; old-growth taiga is podzol; + stony shores/peaks are bare rock; groves and snowy slopes are snow blocks. +- [ ] Trading islands too: a desert or badlands INDUSTRIAL port is a sand/red-sand + island. The plaza and quay are unchanged (their own surfaces). +- [ ] Spawn (mangrove FISHING) generates mud in NEW chunks - a seam against old + grass chunks is expected on this test world; a fresh world is seamless. + +## Islet structures (galaxy.islet-structure-chance, default 0.25) + +- [ ] Roughly one islet in four has something at its heart: igloo on snowy islets, + half-buried fossils on desert/badlands/swamp ones, a ruined portal (check the + loot chest works) in jungle/forest, tents/log piles/target ranges on + plains/savanna. +- [ ] Same seed, same islet, same structure after a world regen (determinism). +- [ ] Mushroom and pale garden islets NEVER have one. +- [ ] Structures sit sensibly: fossils partly buried, igloo floor flush, no + floating or truncated templates at chunk edges. +- [ ] Set the chance to 0 and generate new area: no structures; back to 0.25: they + return (safe mid-game toggle). + +## The plaza galley: public crafting and cooking + +- [x] Every plaza has a crafting table and a campfire on a cobblestone hearth near + where the quay meets the plaza (NEW chunks only - previously generated + plazas keep their old layout). +- [x] As a plain visitor: open the crafting table (3x3 grid works) and cook raw cod + on the campfire (right-click with the fish, collect cooked cod). +- [ ] Cooked/crafted goods are unstamped: the market refuses to buy them (eat your + catch, don't sell it). +- [x] The table and campfire cannot be broken by visitors. +- [x] Villagers and golems do not walk into the campfire (watch a minute or two). + +# ============ THE GREAT REFIT (virtual hold / One Boat / tech) ============ +# PREREQUISITE: wipe the TradeWinds world folders AND the TradeWinds database +# tables (PlayerHold, DroppedBoat, TWPlayerData, TWIslandData) - clean slate, +# no migration. Old pouches/stamped goods/shulker expanders are dead items. + +## Tech levels + +- [x] Every island announce/market/chart line shows its tech: "Esedaxe - + FISHING (Tech 2), Safe" etc.; nav bossbar shows "Tech N". +- [ ] The starter cluster contains at least one Tech 3+ island. +- [ ] Prices tilt: iron/gold ingots cheaper at a Tech 6 INDUSTRIAL island than + a Tech 2 one; raw ore sells for MORE at high tech. +- [x] Plaza amenities by tech: TL3+ furnace + stonecutter; TL4+ smithing table + + grindstone; TL5+ brewing stand + cauldron; TL6+ anvil; TL7 enchanting + table with a bookshelf arc. All usable as a visitor, none breakable. + +## Virtual hold and the GUI + +- [x] New player: Oak Boat, 8 coal already in the FUEL row, $250. No pouch. +- [x] Open the hold: sneak-right-click the boat (confirmed working); + right-click riding (empty hand); right-click the boat item in your + inventory screen; and in a CHEST-variant boat, just press your inventory + key while riding - the hold opens like a vanilla chest boat (top = hold, + bottom = your inventory). NOTE: in a plain (non-chest) boat the inventory + key cannot open the hold - the client never tells the server about it - + so use the right-click gestures there. +- [x] A chest boat's REAL chest inventory is unreachable by every route + (inventory key, sneak-click, riding click) - the hold GUI answers instead. +- [x] The GUI: TNT top-left, 3/21 cargo slots open (rest gray), 7 fuel slots + (pink panes when empty). +- [x] Click items in your pack to deposit: coal/logs/lava go to FUEL; bundles, + chests, shulkers and boats are REFUSED; anything else becomes cargo. +- [x] NOTHING gets cargo back out: shift-clicks, number keys, drags, cursor + tricks all dead. Fuel clicks DO come back out. +- [x] Select a cargo stack, click TNT: destroyed (gone, not dropped). +- [x] Coal/logs BOUGHT at market sit in cargo; clicking that cargo stack moves + it to the fuel row (works inside expander panels too). Fuel can never be + TNT-destroyed - clicking fuel-valued cargo always moves it instead. +- [ ] Deposited items lose enchantments/names by design - cargo is commodity. +- [ ] Sell at a market: anything aboard sells (no stamps); buying fills the + hold and stops at capacity; balance/slots line is right. + +## One Boat + +- [x] Shipwright lists only boats BIGGER than yours, only up to TL x 3; buying + swaps your boat in place (riding: re-seated in the new boat) and your + cargo/fuel are untouched. The old boat is GONE. +- [ ] Crafting a smaller/equal boat is refused with a message; crafting bigger + replaces like the shop. Boat+chest recipe upgrades in place. +- [ ] Charity (no boat, broke): a bamboo raft. /tw restart: everything reset. + +## Dropped boats + +- [ ] Q-drop your boat item: cargo goes WITH it. Another player picks it up: + no boat = it becomes theirs, contents intact; smaller boat = they strip + the valuables first, hull+remainder stays. +- [ ] Bigger-boat salvage is a SWAP: full oak boat + full cherry chest wreck = + you sail the cherry chest with your cargo plus the most valuable salvage, + and your old oak floats where the wreck was, holding the overflow. Count + the items: NOTHING is lost. If everything fit, the old hull does NOT + linger - it is consumed by the upgrade. +- [ ] A PLAIN boat item (command-given, or a spare hull) upgrades on pickup + too: /give yourself an OAK_CHEST_BOAT, drop it, pick it up - it replaces + your oak boat, your cargo stays, and the old oak is gone. A smaller + plain boat just sits in your pack; a boatless player claims it. +- [ ] Break a ridden boat (punch/trident): always an item, never destruction; + pick it back up and your hold survives the round trip. +- [ ] Boat + hold burn ONLY in lava (boat entity in lava, or fire/lava death). +- [ ] Death drops the tagged boat; DeathChest servers: it is in the chest. +- [ ] Respawning boatless at spawn: the port lends you a BAMBOO_RAFT (message + says your own boat is adrift where you fell). With keepInventory on you + keep your boat and get NO loaner. `boats.respawn-boat: NONE` disables it; + an invalid material name also just disables it. +- [ ] Tagged boat items never despawn at 5 min; they sink when the TTL + (default 30 min, survives restart) runs out. + +## Expanders v2 + +- [ ] Sold only at Tech 7 shipwrights; refuse without a Pale Oak Chest Boat + with a free slot; price 5000, 10000, 20000... +- [ ] Installed expander shows as a white shulker in the hold GUI; click opens + its own 21-slot panel (own TNT, no fuel row); refuses containers. +- [ ] Right-click an EMPTY expander + TNT destroys it; loaded ones refuse. +- [ ] In any smaller boat (after salvage) expanders are inert but their + contents still sell at market. + +## Visible boundaries (border.* config) + +- [ ] Approaching an island, a BLUE dust curtain marks the edge of island space + (where scans begin), and further in a RED curtain marks the warp-offer + ring at the protection edge. Both render only the arc near you (~32 + blocks) and only when you are close. +- [ ] Both are pure paint: you row straight through them. +- [ ] Colors change with border.warp-ring-color / border.edge-color (r,g,b); + a nonsense value falls back to the defaults instead of vanishing. +- [ ] border.particles-enabled: false removes both curtains. + +## The warp dialog fires AT the red curtain + +- [ ] The dialog opens only as you touch the red particle wall (~5 blocks + either side of it), not 30 blocks early. Rowing up short of the curtain: + nothing. NOTE: needs `travel.warp.trigger-distance: 5` in the LIVE + config - the jar default changed but an existing config keeps its 30. +- [ ] Sprint across an icy approach lane through the curtain: the dialog must + still fire (the window is small but not skippable). + +## Warp arrivals face the dock + +- [ ] Both the BOAT and your view come out of a warp pointed at the dock - + mounting aligns the hull to the rider, so both rotations are set after + the re-seat. + +- [ ] Warp to any island: you and your boat arrive pointing AT the pier - + paddle straight forward and you reach the dock. Check from a couple of + different origin islands (arrival side changes; the facing must not). + +## Boat ownership, capture and protection (2026-08-02 rules) + +- [ ] The boat ITEM shows its manifest as lore: "Cargo: X/Y slots", + "Fuel: N units", "Right-click to open the hold" - and the numbers update + after you close the hold GUI (deposit fuel, close, hover the item). +- [ ] The starter message names the Oak Boat, its 3 slots and the fuel tank, + and says to right-click the boat; arriving at spawn says you come + ashore at the spawn port (no more "set sail" while standing on planks). +- [ ] Place your own boat from the item and board it: NO capture dialog, no + OLD BOAT marker, and your fuel/cargo are still aboard (regression: the + placed entity used to lose its identity). +- [ ] Teleport (/tw spawn) while riding: the boat item in your pack still + carries its cargo and fuel when you place it again. +- [ ] Name plates: an unattended boat shows its owner's name; an abandoned one + shows UNOWNED. The plate hides while someone is riding it. +- [ ] Trading needs your ship: leave your boat outside the island's protection + range (or on another island) and the market refuses with "your ship is + not at this port". Carrying the boat as an item counts as having it. +- [ ] `/tw chart` raises the hologram compass ashore as well as afloat (text + list only via `/tw chart list`): green BOAT marker at your ship, red + OLD BOAT at one you abandoned - from anywhere in the world. Walk away + from a moored boat, run it, follow the marker back. +- [ ] While SITTING in your boat there is no BOAT marker (you are in it), nor + while CARRYING it as an item, nor when it is within ~32 blocks - a + marker is only for a boat you must travel to. +- [ ] After swapping boats at your feet, neither BOAT nor OLD BOAT clutters + the chart; walk a few hundred blocks from an abandoned LOADED hull and + the OLD BOAT marker does appear. +- [ ] `/tw chart list` names both boats with coordinates and distance ("Your + Oak Boat: x, z (N blocks)" / "Old Bamboo Raft: ... unowned"), or says + plainly that you have no ship / that a boat is in another world. Use + this when a hologram marker is missing: it tells you whether the boat + is gone, elsewhere, or just not drawing. +- [ ] Capture a boat: the one you left changes its plate to UNOWNED + immediately (regression: it kept showing the old owner's name). +- [ ] Board someone else's unattended boat in SAFE/POLICED/FRONTIER/LAWLESS + island space: refused ("this port protects it"). Same for breaking it, + including mob damage. On an ANARCHIC island: allowed. +- [ ] Board an UNOWNED boat anywhere (even at a safe dock): confirmation + dialog; accept and it becomes yours, your previous boat becomes UNOWNED + where it lies with your cargo still in it, charted as OLD BOAT. +- [ ] Walk over an unowned boat ITEM with a boat of your own: the dialog opens + once (not every tick) and offers "Take the [boat]", "Take only the cargo" + (only if your own boat is within 200 blocks AND the hull carries + something), and "Leave it". Take the boat: yours, old one becomes OLD + BOAT. Take the cargo: hull stays on the ground, cargo in your hold. +- [ ] Repeat with your own boat on ANOTHER island: the cargo option is GONE + and the dialog says your boat is too far to load. Nothing teleports. +- [ ] Boarding someone's unattended hull offers the same three answers. +- [ ] Die: your boat stays floating where it was and is still yours. Respawn + at spawn with the ship far away: you are lent a bamboo raft. Board the + raft -> confirmation -> your real boat becomes OLD BOAT; row back to it, + board it, and it is yours again (marker clears). +- [ ] Lava: the only thing that destroys a boat and its cargo outright. +- [ ] Log out in protected island space: boat is safe. Log out in open water: + another player can take it; on your next login you are told it was + taken, and given a raft if you are adrift with none. + +## Two-player capture (2026-08-02 regressions) + +- [ ] Player 2 is ONLINE when player 1 takes their boat: player 2 is told + immediately ("Someone has taken your boat!"). +- [ ] Player 2 now has NO boat: the market shows no cargo slots, gives no + low-fuel warnings, and `/tw chart` shows NO old boat (it was taken, not + abandoned - an OLD BOAT marker only ever means unowned and still there). +- [ ] Player 2 can still use the OUTFITTER and the SHIPWRIGHT with no boat - + buying a hull is how they get off the island. Only Sell/Buy cargo are + withheld, with a line saying why. +- [ ] With NO boat, buy a Bamboo Raft ($100): the raft item is IN YOUR PACK, + the money is gone once, and the hold GUI opens on it with 2 slots. +- [ ] With a boat AT the port, buy a bigger hull: the same record refits - the + boat you are riding is swapped under you (or the carried item retypes), + cargo intact. With your boat elsewhere, the yard refuses the refit and + says to bring the ship in. +- [ ] Restart the server: player 2 gets no repeat "taken while you were away" + message, and player 1 still owns the boat (the login path used to strip + the new owner and hand it back). +- [ ] Player 2 offline when it happens: they ARE told at their next login, + once, and still have no OLD BOAT marker. + +## Recovering a boat while carrying one (no duplicates) + +- [ ] Carry your boat as an item, walk to your abandoned OLD BOAT and take it: + you end up with ONE boat item (the one you took), its hold holding both + cargoes, and the old hull lying on the ground unowned. Never two boats + in the pack. +- [ ] Right-clicking a boat item that is NOT your ship does nothing (it used + to open your hold if the material matched). +- [ ] Craft a bigger boat: the crafted item is your ship (right-click opens + the hold), not an anonymous look-alike. +- [ ] With your old boat an island away, taking a new one leaves it untouched + with its cargo - charted as OLD BOAT. diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 232505b..0cf1879 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -128,6 +128,15 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "galaxy.mushroom-islet-chance", needsReset = true) private double mushroomIsletChance = 0.06; + @ConfigComment("Chance (0-1) that a wild islet carries a small biome-appropriate vanilla") + @ConfigComment("structure at its heart: an igloo on the snowfields, fossils in the desert,") + @ConfigComment("a ruined portal in the jungle, an abandoned camp in the woods. Seeded and") + @ConfigComment("deterministic per islet. Mushroom islets and pale gardens always stay") + @ConfigComment("empty - there the biome itself is the find. 0 disables. Safe to change") + @ConfigComment("mid-game: it only affects islets whose center chunk is not yet generated.") + @ConfigEntry(path = "galaxy.islet-structure-chance") + private double isletStructureChance = 0.25; + @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.") @@ -247,7 +256,7 @@ private static Map defaultBandHurtVillagers() { @ConfigComment("Currency charged per point of reputation debt when paying a fine. A fine can") @ConfigComment("only ever take you back to Clean - money buys you out of Wanted, not into virtue.") @ConfigEntry(path = "crime.fine-per-point") - private double finePerPoint = 2.0; + private double finePerPoint = 20.0; @ConfigComment("The safest band that will still trade with a FUGITIVE. Safer ports refuse them") @ConfigComment("outright: having burned your name, the only markets left are where the law is") @@ -383,18 +392,21 @@ public double bountyFor(Crime crime) { private double warpFailureChance = 0.05; @ConfigComment("Half-width of the offer ring around an island's visible border (the protection") - @ConfigComment("edge, where 'Now leaving...' appears): boated players crossing it get the warp dialog.") + @ConfigComment("edge - the RED particle curtain): boated players crossing it get the warp") + @ConfigComment("dialog. Small on purpose: you should have to touch the curtain. Keep a few") + @ConfigComment("blocks of width or ice-lane boats can cross the line between ticks.") @ConfigEntry(path = "travel.warp.trigger-distance") - private int warpTriggerDistance = 30; + private int warpTriggerDistance = 5; @ConfigComment("How far from the destination island's CENTRE a warp arrival lands.") - @ConfigComment("This wants to be at the island's visible border - the same place the warp") - @ConfigComment("dialog offers itself on the way out - so arriving is the mirror of leaving.") - @ConfigComment("130 put sailors inside the island's own 160-block terrain footprint, on its") - @ConfigComment("underwater shelf: too close to feel like a voyage, and too close for a") - @ConfigComment("customs patrol to have anywhere to come from.") + @ConfigComment("This wants to be just INSIDE the warp-offer ring (protection-range minus") + @ConfigComment("trigger-distance, with margin): landing on the ring itself reopened the warp") + @ConfigComment("dialog the sailor had just come through. 130 put sailors inside the island's") + @ConfigComment("own 160-block terrain footprint, on its underwater shelf: too close to feel") + @ConfigComment("like a voyage, and too close for a customs patrol to have anywhere to come") + @ConfigComment("from. 400 sat exactly on the offer ring.") @ConfigEntry(path = "travel.warp.arrival-distance") - private int warpArrivalDistance = 400; + private int warpArrivalDistance = 320; @ConfigComment("Seconds between automatic warp-dialog offers at the same island's border.") @ConfigEntry(path = "travel.warp.prompt-cooldown-seconds") @@ -478,7 +490,7 @@ private static Map defaultFuelValues() { /* ECONOMY */ @ConfigComment("Money given to brand-new players with their starter kit.") @ConfigEntry(path = "economy.starting-balance") - private double startingBalance = 250.0; + private double startingBalance = 2500.0; @ConfigComment("Coal tucked into the starter bundle - enough fuel that the first island hop") @ConfigComment("can be a warp instead of a seven-minute row. 0 disables.") @@ -507,6 +519,91 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "economy.band-demand-bonus") private double bandDemandBonus = 0.125; + @ConfigComment("Price tilt per tech-level step away from 4 (the neutral level): high-tech") + @ConfigComment("islands sell FINISHED goods (metals, food) cheaper and pay more for RAW") + @ConfigComment("goods (ores, crops, wood, fish, stone) by this fraction per step; low-tech") + @ConfigComment("islands the inverse. The best routes are tech differentials. 0 disables.") + @ConfigEntry(path = "economy.tech-price-step") + private double techPriceStep = 0.03; + + @ConfigComment("The boat ladder: cargo slots per boat material. A player has exactly ONE") + @ConfigComment("boat (or none) and this is the ONLY thing that sizes their hold. Order is") + @ConfigComment("by slots; shops list only boats bigger than yours, up to the island's tech") + @ConfigComment("(rank <= tech level x ranks-per-tech-level). Crafting ignores tech gates.") + @ConfigEntry(path = "boats.ranks") + private Map boatRanks = defaultBoatRanks(); + + @ConfigComment("Boat shop price = this x slots squared. Bamboo Raft 100, Pale Oak Chest 11025.") + @ConfigEntry(path = "boats.price-per-slot-squared") + private double boatPricePerSlotSquared = 250.0; + + @ConfigComment("Boat ranks sold at an island = its tech level x this (TL7 sells all 20).") + @ConfigEntry(path = "boats.ranks-per-tech-level") + private int boatRanksPerTechLevel = 3; + + @ConfigComment("Show island boundaries as particle curtains: the warp-offer ring at the") + @ConfigComment("protection edge, and the edge of island space. Paint, not a wall - both") + @ConfigComment("stay fully passable.") + @ConfigEntry(path = "border.particles-enabled") + private boolean borderParticlesEnabled = true; + + @ConfigComment("Blocks from a boundary line within which its curtain renders.") + @ConfigEntry(path = "border.view-distance") + private int borderViewDistance = 32; + + @ConfigComment("Dust color 'r,g,b' (0-255) of the warp-offer ring - where the warp dialog") + @ConfigComment("fires, at the island's protection edge. Default red.") + @ConfigEntry(path = "border.warp-ring-color") + private String warpRingColor = "255,64,64"; + + @ConfigComment("Dust color 'r,g,b' (0-255) of the island-space edge - where scans and") + @ConfigComment("island rules begin. Default blue.") + @ConfigEntry(path = "border.edge-color") + private String edgeRingColor = "64,128,255"; + + @ConfigComment("How close your own boat must be for you to move another hull's cargo into") + @ConfigComment("it. Beyond this the option is not offered at all - cargo is not teleported") + @ConfigComment("across the ocean; you may still take the boat itself.") + @ConfigEntry(path = "boats.cargo-transfer-range") + private int cargoTransferRange = 200; + + @ConfigComment("The boat a boatless player is lent when they respawn (their own boat") + @ConfigComment("dropped where they died - rowing back out to reclaim it is the recovery") + @ConfigComment("trip). A boat-rank material name; NONE disables the loaner.") + @ConfigEntry(path = "boats.respawn-boat") + private String respawnBoat = "BAMBOO_RAFT"; + + @ConfigComment("Minutes a dropped boat item (and the cargo riding in its record) survives") + @ConfigComment("before the sea claims it. Persisted in the database - restarts do not") + @ConfigComment("reset the clock. The TTL pauses while a mob is holding the item.") + @ConfigEntry(path = "boats.dropped-boat-ttl-minutes") + private int droppedBoatTtlMinutes = 30; + + private static Map defaultBoatRanks() { + Map ranks = new java.util.LinkedHashMap<>(); + ranks.put("BAMBOO_RAFT", 2); + ranks.put("OAK_BOAT", 3); + ranks.put("SPRUCE_BOAT", 4); + ranks.put("BAMBOO_CHEST_RAFT", 5); + ranks.put("BIRCH_BOAT", 6); + ranks.put("OAK_CHEST_BOAT", 7); + ranks.put("JUNGLE_BOAT", 8); + ranks.put("SPRUCE_CHEST_BOAT", 9); + ranks.put("ACACIA_BOAT", 10); + ranks.put("BIRCH_CHEST_BOAT", 11); + ranks.put("DARK_OAK_BOAT", 12); + ranks.put("JUNGLE_CHEST_BOAT", 13); + ranks.put("MANGROVE_BOAT", 14); + ranks.put("ACACIA_CHEST_BOAT", 15); + ranks.put("CHERRY_BOAT", 16); + ranks.put("DARK_OAK_CHEST_BOAT", 17); + ranks.put("PALE_OAK_BOAT", 18); + ranks.put("MANGROVE_CHEST_BOAT", 19); + ranks.put("CHERRY_CHEST_BOAT", 20); + ranks.put("PALE_OAK_CHEST_BOAT", 21); + return ranks; + } + @ConfigComment("Stock units for a full price swing. Selling this much of a category to one") @ConfigComment("island drives its prices to the drift minimum.") @ConfigEntry(path = "economy.drift-scale") @@ -526,38 +623,18 @@ private static Map defaultFuelValues() { @ConfigComment("Base price of the first cargo expander. Each further one costs double.") @ConfigEntry(path = "economy.expander-base-price") - private double expanderBasePrice = 5000.0; - - @ConfigComment("Maximum cargo expanders a player may ever buy.") - @ConfigEntry(path = "economy.expander-cap") - private int expanderCap = 4; - - @ConfigComment("Maximum trading pouches (bundles) that count as hold space.") - @ConfigEntry(path = "economy.max-bundles") - private int maxBundles = 3; - - @ConfigComment("Price of a trading pouch. Deliberately cheap: with no pouch a player has no") - @ConfigComment("hold, and with no hold they can neither buy nor sell - so a pouch is the price") - @ConfigComment("of being able to play at all, not an upgrade. Flat by design: pricing by how") - @ConfigComment("many you carry is defeated by dropping one before buying, and the max-bundles") - @ConfigComment("cap - not the price - is what makes cargo expanders necessary.") - @ConfigEntry(path = "economy.pouch-price") - private double pouchPrice = 50.0; - - @ConfigComment("The harbourmaster's charity: a destitute sailor - no cargo space, no boat, and") - @ConfigComment("too little money to buy either - is given the bare minimum to work again.") - @ConfigComment("Charity goods cannot be sold, so there is nothing to farm. 0 disables it.") + private double expanderBasePrice = 50000.0; + + @ConfigComment("The harbourmaster's charity: a destitute sailor - no boat, and too little") + @ConfigComment("money to buy even a bamboo raft - is granted one, so the economy is never") + @ConfigComment("fully closed to them. 0 disables it.") @ConfigEntry(path = "economy.charity-cooldown-minutes") private int charityCooldownMinutes = 15; - @ConfigComment("Give customs-stamped goods an enchantment glint as well as their lore line.") - @ConfigEntry(path = "economy.stamp-glint") - private boolean stampGlint = false; - - @ConfigComment("Materials traders buy even WITHOUT a customs stamp - the contraband exceptions.") - @ConfigComment("Only honored while illegal-trade.enabled is true.") - @ConfigEntry(path = "economy.unstamped-sellables") - private List unstampedSellables = new ArrayList<>(List.of("SUGAR")); + @ConfigComment("The contraband list: what customs care about, and what black markets pay") + @ConfigComment("the premium for. Only honored while illegal-trade.enabled is true.") + @ConfigEntry(path = "illegal-trade.contraband-materials") + private List contrabandMaterials = new ArrayList<>(List.of("SUGAR")); @ConfigComment("Career restarts a destitute player may use (/tw restart): fresh kit, starting") @ConfigComment("balance, chart kept. -1 = unlimited, 0 = none.") @@ -571,28 +648,28 @@ private static Map defaultFuelValues() { private static Map defaultBasePrices() { Map map = new HashMap<>(); - map.put("WHEAT", 2.0); map.put("CARROT", 1.5); map.put("POTATO", 1.5); map.put("BEETROOT", 1.5); - map.put("SUGAR_CANE", 1.0); map.put("SUGAR", 1.5); map.put("PUMPKIN", 3.0); map.put("MELON_SLICE", 0.5); - map.put("BREAD", 3.0); map.put("COOKED_BEEF", 4.0); map.put("COOKED_COD", 3.0); map.put("CAKE", 20.0); - map.put("GOLDEN_APPLE", 150.0); map.put("EGG", 1.0); map.put("HAY_BLOCK", 18.0); - map.put("COD", 2.0); map.put("SALMON", 3.0); map.put("TROPICAL_FISH", 5.0); map.put("PUFFERFISH", 4.0); - map.put("KELP", 0.3); - map.put("OAK_LOG", 1.5); map.put("SPRUCE_LOG", 1.5); map.put("BIRCH_LOG", 1.5); map.put("DARK_OAK_LOG", 1.5); - map.put("ACACIA_LOG", 1.5); map.put("JUNGLE_LOG", 1.5); map.put("CHERRY_LOG", 2.0); - map.put("STONE", 0.5); map.put("COBBLESTONE", 0.3); map.put("GRANITE", 0.4); map.put("DIORITE", 0.4); - map.put("ANDESITE", 0.4); map.put("DEEPSLATE", 0.6); map.put("SAND", 0.3); map.put("GRAVEL", 0.3); - map.put("COAL", 4.0); map.put("CHARCOAL", 3.0); map.put("RAW_IRON", 6.0); map.put("RAW_COPPER", 3.0); - map.put("RAW_GOLD", 12.0); map.put("FLINT", 1.0); - map.put("IRON_INGOT", 9.0); map.put("COPPER_INGOT", 4.0); map.put("GOLD_INGOT", 18.0); - map.put("IRON_NUGGET", 1.0); map.put("GOLD_NUGGET", 2.0); - map.put("DIAMOND", 100.0); map.put("EMERALD", 60.0); map.put("AMETHYST_SHARD", 10.0); - map.put("QUARTZ", 8.0); map.put("LAPIS_LAZULI", 6.0); map.put("REDSTONE", 3.0); - map.put("LEATHER", 4.0); map.put("WHITE_WOOL", 2.0); map.put("STRING", 1.5); - map.put("BEEF", 2.5); map.put("PORKCHOP", 2.5); map.put("CHICKEN", 2.0); map.put("MUTTON", 2.0); + map.put("WHEAT", 20.0); map.put("CARROT", 15.0); map.put("POTATO", 15.0); map.put("BEETROOT", 15.0); + map.put("SUGAR_CANE", 10.0); map.put("SUGAR", 15.0); map.put("PUMPKIN", 30.0); map.put("MELON_SLICE", 5.0); + map.put("BREAD", 30.0); map.put("COOKED_BEEF", 40.0); map.put("COOKED_COD", 30.0); map.put("CAKE", 200.0); + map.put("GOLDEN_APPLE", 1500.0); map.put("EGG", 10.0); map.put("HAY_BLOCK", 180.0); + map.put("COD", 20.0); map.put("SALMON", 30.0); map.put("TROPICAL_FISH", 50.0); map.put("PUFFERFISH", 40.0); + 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); + 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); + map.put("RAW_GOLD", 120.0); map.put("FLINT", 10.0); + map.put("IRON_INGOT", 90.0); map.put("COPPER_INGOT", 40.0); map.put("GOLD_INGOT", 180.0); + map.put("IRON_NUGGET", 10.0); map.put("GOLD_NUGGET", 20.0); + map.put("DIAMOND", 1000.0); map.put("EMERALD", 600.0); map.put("AMETHYST_SHARD", 100.0); + map.put("QUARTZ", 80.0); map.put("LAPIS_LAZULI", 60.0); map.put("REDSTONE", 30.0); + map.put("LEATHER", 40.0); map.put("WHITE_WOOL", 20.0); map.put("STRING", 15.0); + map.put("BEEF", 25.0); map.put("PORKCHOP", 25.0); map.put("CHICKEN", 20.0); map.put("MUTTON", 20.0); // Hulls: priced above raw plank cost - shipwright labor. Frugal // players craft their own from wild-islet timber. - map.put("OAK_BOAT", 20.0); - map.put("OAK_CHEST_BOAT", 60.0); + map.put("OAK_BOAT", 200.0); + map.put("OAK_CHEST_BOAT", 600.0); return map; } @@ -758,7 +835,7 @@ private static Map defaultEncounterChance() { @ConfigComment("Fine per contraband item seized when caught, on top of losing the cargo and") @ConfigComment("the reputation. A player who cannot cover it pays what they have.") @ConfigEntry(path = "illegal-trade.smuggling-fine-per-item") - private double smugglingFinePerItem = 5.0; + private double smugglingFinePerItem = 50.0; @ConfigComment("Tell players when a scan finds nothing. On by default: being waved through is") @ConfigComment("how a player learns the mechanic exists before it costs them anything.") @@ -2778,6 +2855,8 @@ public void setConcurrentIslands(int concurrentIslands) { public void setWildIsletGrid(int wildIsletGrid) { this.wildIsletGrid = wildIsletGrid; } public double getMushroomIsletChance() { return mushroomIsletChance; } public void setMushroomIsletChance(double mushroomIsletChance) { this.mushroomIsletChance = mushroomIsletChance; } + public double getIsletStructureChance() { return isletStructureChance; } + public void setIsletStructureChance(double isletStructureChance) { this.isletStructureChance = isletStructureChance; } public double getCoastRoughness() { return coastRoughness; } public void setCoastRoughness(double coastRoughness) { this.coastRoughness = coastRoughness; } public double getIslandHilliness() { return islandHilliness; } @@ -2923,6 +3002,28 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setDemandFactor(double demandFactor) { this.demandFactor = demandFactor; } public double getBandDemandBonus() { return bandDemandBonus; } public void setBandDemandBonus(double bandDemandBonus) { this.bandDemandBonus = bandDemandBonus; } + public double getTechPriceStep() { return techPriceStep; } + public void setTechPriceStep(double techPriceStep) { this.techPriceStep = techPriceStep; } + public Map getBoatRanks() { return boatRanks; } + public void setBoatRanks(Map boatRanks) { this.boatRanks = boatRanks; } + public double getBoatPricePerSlotSquared() { return boatPricePerSlotSquared; } + public void setBoatPricePerSlotSquared(double boatPricePerSlotSquared) { this.boatPricePerSlotSquared = boatPricePerSlotSquared; } + public int getBoatRanksPerTechLevel() { return boatRanksPerTechLevel; } + public void setBoatRanksPerTechLevel(int boatRanksPerTechLevel) { this.boatRanksPerTechLevel = boatRanksPerTechLevel; } + public boolean isBorderParticlesEnabled() { return borderParticlesEnabled; } + public void setBorderParticlesEnabled(boolean borderParticlesEnabled) { this.borderParticlesEnabled = borderParticlesEnabled; } + public int getBorderViewDistance() { return borderViewDistance; } + public void setBorderViewDistance(int borderViewDistance) { this.borderViewDistance = borderViewDistance; } + public String getWarpRingColor() { return warpRingColor; } + public void setWarpRingColor(String warpRingColor) { this.warpRingColor = warpRingColor; } + public String getEdgeRingColor() { return edgeRingColor; } + public void setEdgeRingColor(String edgeRingColor) { this.edgeRingColor = edgeRingColor; } + public int getCargoTransferRange() { return cargoTransferRange; } + public void setCargoTransferRange(int cargoTransferRange) { this.cargoTransferRange = cargoTransferRange; } + public String getRespawnBoat() { return respawnBoat; } + public void setRespawnBoat(String respawnBoat) { this.respawnBoat = respawnBoat; } + public int getDroppedBoatTtlMinutes() { return droppedBoatTtlMinutes; } + public void setDroppedBoatTtlMinutes(int droppedBoatTtlMinutes) { this.droppedBoatTtlMinutes = droppedBoatTtlMinutes; } public int getDriftScale() { return driftScale; } public void setDriftScale(int driftScale) { this.driftScale = driftScale; } public double getDriftMin() { return driftMin; } @@ -2933,18 +3034,10 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setStockDecayPerHour(int stockDecayPerHour) { this.stockDecayPerHour = stockDecayPerHour; } public double getExpanderBasePrice() { return expanderBasePrice; } public void setExpanderBasePrice(double expanderBasePrice) { this.expanderBasePrice = expanderBasePrice; } - public int getExpanderCap() { return expanderCap; } - public void setExpanderCap(int expanderCap) { this.expanderCap = expanderCap; } - public int getMaxBundles() { return maxBundles; } - public void setMaxBundles(int maxBundles) { this.maxBundles = maxBundles; } - public double getPouchPrice() { return pouchPrice; } - public void setPouchPrice(double pouchPrice) { this.pouchPrice = pouchPrice; } public int getCharityCooldownMinutes() { return charityCooldownMinutes; } public void setCharityCooldownMinutes(int charityCooldownMinutes) { this.charityCooldownMinutes = charityCooldownMinutes; } - public boolean isStampGlint() { return stampGlint; } - public void setStampGlint(boolean stampGlint) { this.stampGlint = stampGlint; } - public List getUnstampedSellables() { return unstampedSellables; } - public void setUnstampedSellables(List unstampedSellables) { this.unstampedSellables = unstampedSellables; } + public List getContrabandMaterials() { return contrabandMaterials; } + public void setContrabandMaterials(List contrabandMaterials) { this.contrabandMaterials = contrabandMaterials; } public int getMaxRestarts() { return maxRestarts; } public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } public Map getBasePrices() { return basePrices; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 39b2392..45478cf 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -44,6 +44,7 @@ import world.bentobox.tradewinds.crime.PoliceService; import world.bentobox.tradewinds.crime.ReputationService; import world.bentobox.tradewinds.crime.Standing; +import world.bentobox.tradewinds.dataobjects.HoldManager; import world.bentobox.tradewinds.dataobjects.IslandDataManager; import world.bentobox.tradewinds.dataobjects.PlayerDataManager; import world.bentobox.tradewinds.economy.MarketService; @@ -55,6 +56,7 @@ import world.bentobox.tradewinds.listeners.IntersticePortalListener; import world.bentobox.tradewinds.listeners.ResidentProtectionListener; import world.bentobox.tradewinds.tasks.FuelWarningTask; +import world.bentobox.tradewinds.tasks.BorderCurtainTask; import world.bentobox.tradewinds.tasks.NavigationBarTask; import world.bentobox.tradewinds.tasks.ResidentAuditTask; import world.bentobox.tradewinds.travel.BoatPickupListener; @@ -62,7 +64,8 @@ import world.bentobox.tradewinds.travel.ChartHolograms; import world.bentobox.tradewinds.travel.DialogGuard; import world.bentobox.tradewinds.travel.ChartingListener; -import world.bentobox.tradewinds.travel.ExpanderListener; +import world.bentobox.tradewinds.travel.BoatRanks; +import world.bentobox.tradewinds.travel.BoatService; import world.bentobox.tradewinds.travel.FuelService; import world.bentobox.tradewinds.travel.IntersticeService; import world.bentobox.tradewinds.travel.HoldService; @@ -104,6 +107,11 @@ public class TradeWinds extends GameModeAddon { private RouteGraph routeGraph; private StarterKit starterKit; private HoldService holdService; + private HoldManager holdManager; + private BoatRanks boatRanks; + private BoatService boatService; + private world.bentobox.tradewinds.travel.HoldGui holdGui; + private world.bentobox.tradewinds.travel.BoatListener boatListener; private IslandDataManager islandDataManager; private MarketService marketService; private TradeDialog tradeDialog; @@ -119,6 +127,7 @@ public class TradeWinds extends GameModeAddon { private NamespacedKey policeKey; private @Nullable ResidentAuditTask residentAuditTask; private @Nullable NavigationBarTask navigationBarTask; + private @Nullable BorderCurtainTask borderCurtainTask; private @Nullable FuelWarningTask fuelWarningTask; private SeaPositionTracker seaPositionTracker; private DialogGuard dialogGuard; @@ -256,8 +265,19 @@ public void onEnable() { routeGraph = new RouteGraph(getSettings().getFuelPerBlock(), getSettings().getEdgeOverrides()); warpService = new WarpService(this); starterKit = new StarterKit(this); - // Economy: hold, market data, prices, trade dialogs + // Economy: the virtual hold, market data, prices, trade dialogs + holdManager = new HoldManager(this); + boatRanks = new BoatRanks(this); + boatService = new BoatService(this); holdService = new HoldService(this); + holdGui = new world.bentobox.tradewinds.travel.HoldGui(this); + registerListener(holdGui); + // Crafting a boat obeys the One Boat Rule: bigger replaces, smaller refused + registerListener(new world.bentobox.tradewinds.travel.BoatCraftListener(this)); + // Boats: capture, protection, name plates, TTL on hulls left as items + boatListener = new world.bentobox.tradewinds.travel.BoatListener(this); + boatListener.start(); + registerListener(boatListener); islandDataManager = new IslandDataManager(this); marketService = new MarketService(this); tradeDialog = new TradeDialog(this); @@ -275,9 +295,6 @@ public void onEnable() { // Where a sailor left the ocean, so coming back is not a teleport seaPositionTracker = new SeaPositionTracker(this); registerListener(seaPositionTracker); - // Cargo expanders open in the hand (Java cannot open shulkers in an - // inventory, and a carried hold has to be openable) - registerListener(new ExpanderListener(this)); // Risk at sea: the interstice for warpers, encounters for rowers intersticeService = new IntersticeService(this); intersticeService.start(); @@ -308,6 +325,9 @@ public void onEnable() { // Navigation boss bar: island, standing, distance to dock navigationBarTask = new NavigationBarTask(this); navigationBarTask.start(); + // The visible sea-lanes: red warp ring, blue island edge + borderCurtainTask = new BorderCurtainTask(this); + borderCurtainTask.start(); // "You cannot afford to leave" - said at the port, where it is fixable fuelWarningTask = new FuelWarningTask(this); fuelWarningTask.start(); @@ -324,6 +344,9 @@ public void onDisable() { if (navigationBarTask != null) { navigationBarTask.stop(); } + if (borderCurtainTask != null) { + borderCurtainTask.stop(); + } if (fuelWarningTask != null) { fuelWarningTask.stop(); } @@ -351,6 +374,12 @@ public void onDisable() { if (playerDataManager != null) { playerDataManager.saveAll(); } + if (boatListener != null) { + boatListener.stop(); + } + if (holdManager != null) { + holdManager.saveAll(); + } if (islandDataManager != null) { islandDataManager.saveAll(); } @@ -520,6 +549,34 @@ public HoldService getHoldService() { return holdService; } + /** + * @return the virtual hold database manager + */ + public HoldManager getHoldManager() { + return holdManager; + } + + /** + * @return the boat ladder + */ + public BoatRanks getBoatRanks() { + return boatRanks; + } + + /** + * @return the physical-boat keeper + */ + public BoatService getBoatService() { + return boatService; + } + + /** + * @return the hold GUI + */ + public world.bentobox.tradewinds.travel.HoldGui getHoldGui() { + return holdGui; + } + public IslandDataManager getIslandDataManager() { return islandDataManager; } diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java index 1155a58..877d86b 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminIslandsCommand.java @@ -59,6 +59,7 @@ public boolean execute(User user, String label, List args) { "[index]", String.valueOf(i + 1), TextVariables.NAME, s.name(), "[type]", s.type().name(), + "[tech]", String.valueOf(s.techLevel()), "[band]", s.band().getDisplayName(), "[x]", String.valueOf(s.centerX()), "[z]", String.valueOf(s.centerZ()), diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java index 18fd276..a4ad002 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWChartCommand.java @@ -38,11 +38,12 @@ public boolean execute(User user, String label, List args) { user.sendMessage("tradewinds.chart.port-scan", TextVariables.NUMBER, String.valueOf(scanned.size())); } - // In a boat (and not asking for the text list): raise the hologram - // compass - visual targets to row toward + // The hologram compass, afloat OR ashore: a sailor hunting their + // moored boat needs the BOAT marker exactly when they are NOT in it + // (playtest 2026-08-02: the text list was "not useful" for finding + // it). The text listing stays behind '/tw chart list'. boolean wantList = !args.isEmpty() && args.get(0).equalsIgnoreCase("list"); - if (!wantList && user.getPlayer().getVehicle() instanceof org.bukkit.entity.Boat - && getWorld().equals(user.getWorld())) { + if (!wantList && getWorld().equals(user.getWorld())) { addon.getChartHolograms().show(user.getPlayer()); user.sendMessage("tradewinds.chart.holograms-shown"); return true; @@ -66,12 +67,15 @@ && getWorld().equals(user.getWorld())) { IslandSpec origin = portAt(engine, x, z).orElse(null); user.sendMessage("tradewinds.chart.header", TextVariables.NUMBER, String.valueOf(charted.size())); user.sendMessage("tradewinds.chart.fuel-aboard", "[amount]", String.valueOf((int) fuelAboard)); + reportBoat(addon, user, x, z, false); + reportBoat(addon, user, x, z, true); charted.forEach(spec -> { int cost = routeCost(addon, origin, spec, x, z); boolean reachable = cost <= fuelAboard; user.sendMessage(reachable ? "tradewinds.chart.entry-reachable" : "tradewinds.chart.entry-far", TextVariables.NAME, spec.name(), "[type]", spec.type().name(), + "[tech]", String.valueOf(spec.techLevel()), "[band]", user.getTranslation(spec.band().getLocaleKey()), "[distance]", String.valueOf((int) Math.sqrt(spec.distanceSquared(x, z))), "[fuel]", String.valueOf(cost)); @@ -79,6 +83,45 @@ && getWorld().equals(user.getWorld())) { return true; } + /** + * Where a boat of yours lies, in words: the chart's holograms only draw + * boats in THIS world, so the list has to say when one is missing or + * elsewhere - otherwise a sailor cannot tell "no old boat" from "the + * marker did not draw". + * + * @param addon the addon + * @param user the player + * @param x their block x + * @param z their block z + * @param old true for the abandoned OLD BOAT, false for their own + */ + private void reportBoat(TradeWinds addon, User user, int x, int z, boolean old) { + var record = old ? addon.getHoldManager().oldBoat(user.getUniqueId()) + : addon.getHoldManager().activeBoat(user.getUniqueId()); + String key = old ? "tradewinds.chart.old-boat" : "tradewinds.chart.your-boat"; + if (record.isEmpty()) { + if (!old) { + user.sendMessage("tradewinds.chart.no-boat"); + } + return; + } + var hold = record.get(); + String material = world.bentobox.tradewinds.economy.PriceEngine.prettify(hold.getMaterial()); + if (hold.getWorld() == null || hold.getWorld().isEmpty()) { + user.sendMessage(key + "-lost", "[material]", material); + return; + } + if (!hold.getWorld().equals(user.getWorld().getName())) { + user.sendMessage(key + "-elsewhere", "[material]", material, "[world]", hold.getWorld()); + return; + } + long dx = (long) hold.getX() - x; + long dz = (long) hold.getZ() - z; + user.sendMessage(key, "[material]", material, "[x]", String.valueOf(hold.getX()), + "[z]", String.valueOf(hold.getZ()), + "[distance]", String.valueOf((int) Math.sqrt((double) dx * dx + (double) dz * dz))); + } + /** * The trading island the player is standing in the waters of, if any - * warps launch from an island, so that is the origin a cost is measured diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWFineCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWFineCommand.java index 3b254d2..ce6dd04 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWFineCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWFineCommand.java @@ -58,7 +58,7 @@ public boolean execute(User user, String label, List args) { user.sendMessage("tradewinds.crime.fine-barred"); return false; } - String amount = format(addon, owed); + String amount = world.bentobox.tradewinds.economy.Money.format(addon, owed); user.sendMessage("tradewinds.crime.fine-quote", "[amount]", amount); askConfirmation(user, () -> { if (addon.getReputationService().payFine(user.getPlayer())) { @@ -81,7 +81,4 @@ private Optional portHere(TradeWinds addon, User user) { .filter(s -> s.distanceSquared(x, z) <= (long) range * range).findFirst(); } - private String format(TradeWinds addon, double amount) { - return addon.getPlugin().getVault().map(v -> v.format(amount)).orElse(String.valueOf(amount)); - } } diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWRestartCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWRestartCommand.java index 2ff694d..1f75447 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWRestartCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWRestartCommand.java @@ -48,6 +48,13 @@ private void restart(TradeWinds addon, User user, TWPlayerData data) { addon.getPlugin().getVault().ifPresent(vault -> vault.withdraw(user, vault.getBalance(user))); data.setStarterKitGiven(false); addon.getPlayerDataManager().save(user.getUniqueId()); + // A fresh career means a fresh hold: the old boat and everything in + // it are struck from the record (only the chart survives - knowledge + // is not wealth) + addon.getHoldManager().activeBoat(user.getUniqueId()) + .ifPresent(hold -> addon.getHoldManager().delete(hold.getUniqueId())); + addon.getHoldManager().setActiveBoat(user.getUniqueId(), null); + addon.getHoldManager().clearOldBoat(user.getUniqueId()); // Back to the spawn islet with a fresh kit user.getPlayer().teleport(addon.getOverWorld().getSpawnLocation()); addon.getStarterKit().onSpawnArrival(user.getPlayer()); diff --git a/src/main/java/world/bentobox/tradewinds/crime/BountyBoard.java b/src/main/java/world/bentobox/tradewinds/crime/BountyBoard.java index 6cb0ba4..a6ed2e3 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/BountyBoard.java +++ b/src/main/java/world/bentobox/tradewinds/crime/BountyBoard.java @@ -108,7 +108,7 @@ public void clear(UUID playerId) { private String format(double amount) { return addon.getPlugin().getVault().map(vault -> vault.format(amount)) - .orElse(String.format("%.0f", amount)); + .orElse(world.bentobox.tradewinds.economy.Money.format(addon, amount)); } /** @@ -121,7 +121,7 @@ private void registerPlaceholders() { placeholders.registerPlaceholder(addon, "bounty", user -> user == null ? "0" : format(addon.getReputationService().bounty(user.getUniqueId()))); placeholders.registerPlaceholder(addon, "bounty_raw", user -> user == null ? "0" - : String.format("%.2f", addon.getReputationService().bounty(user.getUniqueId()))); + : world.bentobox.tradewinds.economy.Money.format(addon, addon.getReputationService().bounty(user.getUniqueId()))); placeholders.registerPlaceholder(addon, "standing", user -> user == null ? "" : addon.getPlayerStanding(user, user.getUniqueId())); placeholders.registerPlaceholder(addon, "reputation", user -> user == null ? "0" diff --git a/src/main/java/world/bentobox/tradewinds/crime/Crime.java b/src/main/java/world/bentobox/tradewinds/crime/Crime.java index b274f61..4d87f9a 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/Crime.java +++ b/src/main/java/world/bentobox/tradewinds/crime/Crime.java @@ -12,15 +12,15 @@ public enum Crime { /** Struck a resident villager. Cheap on its own; it adds up. */ HURT_VILLAGER(-5, 0), /** Killed a resident. The market recovers; your name does not. */ - KILL_VILLAGER(-25, 100), + KILL_VILLAGER(-25, 1000), /** Killed a police unit - golem, guardian or phantom. */ - KILL_POLICE(-15, 150), + KILL_POLICE(-15, 1500), /** Killed a player who was not a lawful target. */ - KILL_INNOCENT(-100, 500), + KILL_INNOCENT(-100, 5000), /** Caught by a customs scan with contraband aboard. */ - SMUGGLING(-30, 200), + SMUGGLING(-30, 2000), /** Sold a villager as a passenger. */ - PASSENGER_TRADE(-20, 150); + PASSENGER_TRADE(-20, 1500); private final int defaultPenalty; private final int defaultBounty; diff --git a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java index 0a5c484..8f9cec7 100644 --- a/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java +++ b/src/main/java/world/bentobox/tradewinds/crime/CustomsService.java @@ -100,7 +100,7 @@ private boolean enabled() { * @return material names customs care about */ public Set contrabandNames() { - return new HashSet<>(addon.getSettings().getUnstampedSellables()); + return new HashSet<>(addon.getSettings().getContrabandMaterials()); } /** @@ -133,7 +133,7 @@ public int contrabandAboard(Player player) { if (material == null) { continue; } - total += addon.getHoldService().count(player, material, stack -> true); + total += addon.getHoldService().count(player, material); total += loose(player, material); } return total; @@ -309,7 +309,7 @@ private int confiscate(Player player) { if (material == null) { continue; } - seized += addon.getHoldService().remove(player, material, Integer.MAX_VALUE, stack -> true); + seized += addon.getHoldService().remove(player, material, Integer.MAX_VALUE); // Pockets too, or the search that found it could not take it for (ItemStack stack : player.getInventory().getContents()) { if (stack != null && stack.getType() == material) { @@ -342,7 +342,7 @@ private double charge(Player player, int seized) { } private String format(double amount) { - return addon.getPlugin().getVault().map(v -> v.format(amount)).orElse(String.valueOf(amount)); + return world.bentobox.tradewinds.economy.Money.format(addon, amount); } /** diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/BoatHold.java b/src/main/java/world/bentobox/tradewinds/dataobjects/BoatHold.java new file mode 100644 index 0000000..f80a61f --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/BoatHold.java @@ -0,0 +1,181 @@ +package world.bentobox.tradewinds.dataobjects; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.annotations.Expose; + +import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.database.objects.Table; + +/** + * A boat and everything in it - the hold, keyed by the BOAT, not the player + * (ruled 2026-08-02). The boat IS the hold: capture it and you take the + * cargo with it, abandon it and the cargo stays aboard, and a two-slot raft + * carries two slots however rich its captain used to be. + *

+ * The record's id lives in a PDC key on both the boat entity and its item + * form, so breaking a boat, dropping it or having a mob carry the item never + * touches the cargo: the avatar is a key, the cargo exists once, here. + *

+ * An unowned boat (owner blank) is fair game for anyone - that is what + * an abandoned OLD BOAT becomes, and unowned boats are never protected, even + * in safe island space. + * + * @author tastybento + */ +@Table(name = "BoatHold") +public class BoatHold implements DataObject { + + @Expose + private String uniqueId; + + /** Boat material name - this is what sets the slot count. */ + @Expose + private String material = ""; + + /** Owning player UUID, or "" for an unowned (capturable) boat. */ + @Expose + private String owner = ""; + + @Expose + private Map contents = new LinkedHashMap<>(); + + @Expose + private Map fuel = new LinkedHashMap<>(); + + @Expose + private List> expanders = new ArrayList<>(); + + /** Last known position, so the chart can point at it from anywhere. */ + @Expose + private String world = ""; + @Expose + private int x; + @Expose + private int y; + @Expose + private int z; + + /** + * Epoch millis when an ITEM-form boat is claimed by the sea. 0 means no + * expiry: boat ENTITIES never expire (ruled 2026-08-02) - only a hull + * lying about as an item does. + */ + @Expose + private long expiresAt; + + public BoatHold() { + // Required by the database + } + + public BoatHold(String uniqueId) { + this.uniqueId = uniqueId; + } + + @Override + public String getUniqueId() { + return uniqueId; + } + + @Override + public void setUniqueId(String uniqueId) { + this.uniqueId = uniqueId; + } + + public String getMaterial() { + return material; + } + + public void setMaterial(String material) { + this.material = material == null ? "" : material; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner == null ? "" : owner; + } + + /** + * @return true if nobody owns this boat - anyone may take or break it + */ + public boolean isUnowned() { + return owner == null || owner.isEmpty(); + } + + public Map getContents() { + return contents; + } + + public void setContents(Map contents) { + this.contents = contents; + } + + public Map getFuel() { + return fuel; + } + + public void setFuel(Map fuel) { + this.fuel = fuel; + } + + public List> getExpanders() { + return expanders; + } + + public void setExpanders(List> expanders) { + this.expanders = expanders; + } + + public String getWorld() { + return world; + } + + public void setWorld(String world) { + this.world = world == null ? "" : world; + } + + public int getX() { + return x; + } + + public void setX(int x) { + this.x = x; + } + + public int getY() { + return y; + } + + public void setY(int y) { + this.y = y; + } + + public int getZ() { + return z; + } + + public void setZ(int z) { + this.z = z; + } + + public long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } + + /** + * @return true if this boat carries nothing at all + */ + public boolean isEmpty() { + return contents.isEmpty() && fuel.isEmpty() && expanders.isEmpty(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/HoldManager.java b/src/main/java/world/bentobox/tradewinds/dataobjects/HoldManager.java new file mode 100644 index 0000000..3bdd357 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/HoldManager.java @@ -0,0 +1,226 @@ +package world.bentobox.tradewinds.dataobjects; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Location; +import org.bukkit.Material; + +import world.bentobox.bentobox.database.Database; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Cache-in-front-of-Database manager for {@link BoatHold} - every boat in the + * galaxy that carries anything or belongs to anyone. Player pointers (which + * boat is theirs, which is their abandoned OLD BOAT) live on + * {@link TWPlayerData}; this class is the single place that moves ownership + * between them, so a boat's cargo exists exactly once however its avatar + * travels. + * + * @author tastybento + */ +public class HoldManager { + + private final TradeWinds addon; + private final Database boats; + private final Map cache = new ConcurrentHashMap<>(); + + public HoldManager(TradeWinds addon) { + this(addon, new Database<>(addon, BoatHold.class)); + } + + /** + * Test constructor with an injectable database, so the ownership rules + * (which are the fiddly part) can be exercised for real rather than + * against a stand-in. + */ + HoldManager(TradeWinds addon, Database boats) { + this.addon = addon; + this.boats = boats; + } + + /** + * A boat record by id, loading it if the cache is cold. + * + * @param boatId the boat id (from the entity/item PDC) + * @return the record, or empty if there is none + */ + public Optional boat(String boatId) { + if (boatId == null || boatId.isEmpty()) { + return Optional.empty(); + } + BoatHold cached = cache.get(boatId); + if (cached != null) { + return Optional.of(cached); + } + if (boats.objectExists(boatId)) { + BoatHold loaded = boats.loadObject(boatId); + if (loaded != null) { + cache.put(boatId, loaded); + return Optional.of(loaded); + } + } + return Optional.empty(); + } + + /** + * Register a brand new boat. + * + * @param material the boat material + * @param owner the owning player, or null for an unowned hull + * @return the fresh record + */ + public BoatHold create(Material material, UUID owner) { + BoatHold hold = new BoatHold(UUID.randomUUID().toString()); + hold.setMaterial(material.name()); + hold.setOwner(owner == null ? "" : owner.toString()); + cache.put(hold.getUniqueId(), hold); + save(hold); + return hold; + } + + public void save(BoatHold hold) { + cache.put(hold.getUniqueId(), hold); + boats.saveObjectAsync(hold); + } + + public void saveAll() { + cache.values().forEach(boats::saveObjectAsync); + } + + public void delete(String boatId) { + cache.remove(boatId); + boats.deleteID(boatId); + } + + /** + * Every boat record - the TTL sweep and admin tooling walk this. + */ + public List allBoats() { + return boats.loadObjects(); + } + + /** + * Remember where a boat was last seen, so {@code /tw chart} can point at + * it from the other side of the ocean. + */ + public void rememberPosition(BoatHold hold, Location where) { + if (where == null || where.getWorld() == null) { + return; + } + hold.setWorld(where.getWorld().getName()); + hold.setX(where.getBlockX()); + hold.setY(where.getBlockY()); + hold.setZ(where.getBlockZ()); + save(hold); + } + + // --------------------------------------------------------- player pointers + + /** + * The player's active boat record - the one that IS their hold. + */ + public Optional activeBoat(UUID playerId) { + return boat(addon.getPlayerDataManager().get(playerId).getActiveBoat()); + } + + /** + * The player's abandoned OLD BOAT, if they have one. It is unowned: this + * is only a memory of where they left it. + */ + public Optional oldBoat(UUID playerId) { + Optional old = boat(addon.getPlayerDataManager().get(playerId).getOldBoat()); + // An OLD BOAT is only a boat that is still out there AND still + // unowned. Once someone claims it (or it is gone) the marker is a + // lie, so forget it rather than point at somebody else's ship. + if (old.isEmpty() || !old.get().isUnowned()) { + if (!addon.getPlayerDataManager().get(playerId).getOldBoat().isEmpty()) { + clearOldBoat(playerId); + } + return Optional.empty(); + } + return old; + } + + /** + * Make a boat this player's active boat. Whatever they had becomes their + * (unowned, capturable) OLD BOAT - exactly one is remembered; anything + * older is simply forgotten flotsam. + * + * @param playerId the player + * @param hold the boat to make active, or null to leave them boatless + */ + public void setActiveBoat(UUID playerId, BoatHold hold) { + TWPlayerData data = addon.getPlayerDataManager().get(playerId); + // Taking a boat takes it FROM someone: the loser stops owning it here + // and now, or their record keeps a boat that is no longer theirs - + // which is how one capture left both players pointing at the same + // hull, each thinking it was their abandoned OLD BOAT (2026-08-02). + if (hold != null && !hold.isUnowned() && !hold.getOwner().equals(playerId.toString())) { + try { + UUID loser = UUID.fromString(hold.getOwner()); + clearActiveBoat(loser); + org.bukkit.entity.Player online = org.bukkit.Bukkit.getPlayer(loser); + if (online != null) { + world.bentobox.bentobox.api.user.User.getInstance(online) + .sendMessage("tradewinds.boat.taken"); + } + } catch (IllegalArgumentException e) { + // Not a UUID - nothing to take it from + } + } + String previous = data.getActiveBoat(); + if (previous != null && !previous.isEmpty() && (hold == null || !previous.equals(hold.getUniqueId()))) { + boat(previous).ifPresent(old -> { + old.setOwner(""); + save(old); + }); + data.setOldBoat(previous); + } + if (hold == null) { + data.setActiveBoat(""); + } else { + data.setActiveBoat(hold.getUniqueId()); + hold.setOwner(playerId.toString()); + save(hold); + // Reclaiming the boat you had abandoned clears the OLD BOAT mark + if (hold.getUniqueId().equals(data.getOldBoat())) { + data.setOldBoat(""); + } + } + addon.getPlayerDataManager().save(playerId); + } + + /** + * Stop pointing at a boat WITHOUT abandoning it: the boat was taken from + * them, or burned. Abandonment (boarding another boat) goes through + * {@link #setActiveBoat} instead, which is what leaves an OLD BOAT. + * + * @param playerId the player who no longer has this boat + */ + public void clearActiveBoat(UUID playerId) { + TWPlayerData data = addon.getPlayerDataManager().get(playerId); + data.setActiveBoat(""); + addon.getPlayerDataManager().save(playerId); + } + + /** + * Forget a player's OLD BOAT marker (it was taken, broken or reclaimed). + */ + public void clearOldBoat(UUID playerId) { + TWPlayerData data = addon.getPlayerDataManager().get(playerId); + data.setOldBoat(""); + addon.getPlayerDataManager().save(playerId); + } + + /** + * Whoever holds this boat as their OLD BOAT, if anyone - used to tell + * them it has been taken. + */ + public Optional ownerOfOldBoat(String boatId) { + return addon.getPlayerDataManager().findByOldBoat(boatId); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java b/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java index 25f831d..4718ae4 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/PlayerDataManager.java @@ -102,6 +102,22 @@ public java.util.List portScan(org. return charted; } + /** + * Whichever cached player remembers this boat as their OLD BOAT. Cache + * only - a boat taken while its former owner is offline is reported to + * them at login instead. + * + * @param boatId the boat id + * @return the player, if any + */ + public java.util.Optional findByOldBoat(String boatId) { + if (boatId == null || boatId.isEmpty()) { + return java.util.Optional.empty(); + } + return cache.entrySet().stream().filter(e -> boatId.equals(e.getValue().getOldBoat())) + .map(Map.Entry::getKey).findFirst(); + } + public void save(UUID playerId) { TWPlayerData data = cache.get(playerId); if (data != null) { diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index 1063142..e90d8df 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -21,6 +21,20 @@ public class TWPlayerData implements DataObject { @Expose private String uniqueId; + /** + * The boat that IS this player's hold (a {@link BoatHold} id), or "" if + * they have none. One boat, always. + */ + @Expose + private String activeBoat = ""; + + /** + * The boat they abandoned by boarding another: unowned, capturable, and + * remembered only so the chart can point them back to it. Exactly one. + */ + @Expose + private String oldBoat = ""; + /** * Charted island cell keys ("cellX,cellZ"). Only charted islands appear in * the warp dialog. @@ -34,12 +48,6 @@ public class TWPlayerData implements DataObject { @Expose private boolean starterKitGiven; - /** - * Cargo expanders bought so far - drives the doubling price and the cap. - */ - @Expose - private int expandersPurchased; - /** * Career restarts consumed (/tw restart). */ @@ -120,12 +128,20 @@ public void setUniqueId(String uniqueId) { this.uniqueId = uniqueId; } - public int getExpandersPurchased() { - return expandersPurchased; + public String getActiveBoat() { + return activeBoat; + } + + public void setActiveBoat(String activeBoat) { + this.activeBoat = activeBoat == null ? "" : activeBoat; + } + + public String getOldBoat() { + return oldBoat; } - public void setExpandersPurchased(int expandersPurchased) { - this.expandersPurchased = expandersPurchased; + public void setOldBoat(String oldBoat) { + this.oldBoat = oldBoat == null ? "" : oldBoat; } public long getLastCharity() { diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index fabe5f3..3287adf 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -3,37 +3,33 @@ import java.util.Optional; import org.bukkit.Material; -import org.bukkit.NamespacedKey; import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; -import org.bukkit.persistence.PersistentDataType; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.hooks.VaultHook; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.api.events.TWTradeEvent; import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.travel.BoatRanks; /** * The market: prices and trades. Base prices come from the embedded * {@link PriceEngine} (config table plus recipe derivation - BlueBook logic - * carried in-addon). Island prices apply the type/band/stock model - * ({@link PriceModel}); all transactions move items through the hold ONLY and - * money through Vault. + * carried in-addon). Island prices apply the type/tech/band/stock model + * ({@link PriceModel}); all transactions move goods through the player's + * VIRTUAL hold only and money through Vault. + *

+ * Customs stamping is gone (hold plan, 2026-08-01): the market buys anything + * the hold carries. What keeps farming from minting free money now is + * capacity (a hold slot is scarce) and the per-island stock pools (selling + * into an island crushes its price toward the drift floor). * * @author tastybento */ public class MarketService { - /** PDC key marking cargo expander items. */ - public static final NamespacedKey EXPANDER_KEY = NamespacedKey.fromString("tradewinds:expander"); - /** PDC key marking customs-stamped (trader-bought) goods - the API marker. */ - public static final NamespacedKey STAMP_KEY = NamespacedKey.fromString("tradewinds:stamp"); - private final TradeWinds addon; private final PriceEngine priceEngine; @@ -64,8 +60,8 @@ public java.util.List saleCatalog(IslandSpec spec) { /** * The outfitter's shelf: survival essentials every island guarantees. * Bread always; fuel (CHARCOAL) unless the trade catalog already sells - * fuel - only the skint AND fuel-less row; plus per-type gear (smiths at - * INDUSTRIAL, beds at AGRICULTURAL, rods at FISHING). + * fuel; plus per-type gear (smiths at INDUSTRIAL, beds at AGRICULTURAL, + * rods at FISHING). */ public java.util.List outfitterCatalog(IslandSpec spec) { java.util.List shelf = new java.util.ArrayList<>(); @@ -79,22 +75,10 @@ public java.util.List outfitterCatalog(IslandSpec spec) { return shelf; } - /** - * The shipwright's slipway: hulls, bought straight to the player's - * inventory (a shipless sailor has no hold to receive into - a vessel is - * not cargo). Cargo expanders complete the shipwright's stock via - * {@link #buyExpander}. - */ - public java.util.List shipwrightCatalog() { - return java.util.List.of(Material.OAK_BOAT, Material.OAK_CHEST_BOAT); - } - /** * Buy stores delivered to the player's INVENTORY, not the hold: outfitter - * supplies (food, gear, beds, rods) and hulls. These are for using, not - * for resale, so they are deliberately NOT customs stamped - which also - * stops the outfitter's shelf becoming an arbitrage route. Anything that - * will not fit is dropped at the player's feet. + * supplies (food, gear, beds, rods). These are for using, not for resale. + * Anything that will not fit is dropped at the player's feet. * * @return how many were bought */ @@ -124,61 +108,18 @@ public int buyToInventory(Player player, IslandSpec spec, Material material, int player.getInventory().addItem(stores).values() .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); user.sendMessage("tradewinds.trade.bought", "[amount]", String.valueOf(affordable), "[material]", - pretty(material), "[price]", String.format("%.2f", total)); + pretty(material), "[price]", Money.format(addon, total)); chime(player); return affordable; } - /** - * Apply the customs stamp: trader-bought goods carry a PDC marker and a - * lore line. Only stamped goods can be sold back to traders - homegrown - * and homemade items are for living with, not for selling (with the - * illegal exceptions the customs office would rather not discuss). - */ - public ItemStack stamp(ItemStack item) { - ItemMeta meta = item.getItemMeta(); - if (meta == null) { - return item; - } - meta.getPersistentDataContainer().set(STAMP_KEY, PersistentDataType.STRING, "stamped"); - java.util.List lore = meta.lore() == null ? new java.util.ArrayList<>() - : new java.util.ArrayList<>(meta.lore()); - lore.add(text("tradewinds.item.stamp")); - meta.lore(lore); - if (addon.getSettings().isStampGlint()) { - meta.setEnchantmentGlintOverride(true); - } - item.setItemMeta(meta); - return item; - } - - /** - * Is this item customs-stamped (trader-bought)? Public API for other - * addons. - */ - public boolean isStamped(ItemStack item) { - return item != null && item.hasItemMeta() - && item.getItemMeta().getPersistentDataContainer().has(STAMP_KEY, PersistentDataType.STRING); - } - - /** - * The filter deciding what traders will buy: stamped goods, plus the - * configured unstamped exceptions (contraband - if the illegal trade is - * enabled at all). - */ - public java.util.function.Predicate sellableFilter() { - return stack -> isStamped(stack) - || (addon.getSettings().isIllegalTradeEnabled() - && addon.getSettings().getUnstampedSellables().contains(stack.getType().name())); - } - /** * The configured price model. */ public PriceModel model() { var s = addon.getSettings(); return new PriceModel(s.getProduceFactor(), s.getDemandFactor(), s.getBandDemandBonus(), s.getBuySpread(), - s.getSellSpread(), s.getDriftScale(), s.getDriftMin(), s.getDriftMax()); + s.getSellSpread(), s.getDriftScale(), s.getDriftMin(), s.getDriftMax(), s.getTechPriceStep()); } /** @@ -207,16 +148,42 @@ public Optional playerSellsAt(IslandSpec spec, Material material) { } /** - * Whether this island will deal with this player at all. - *

- * A fugitive is barred from the safe bands entirely (spec principle 4): - * crime pays, into danger. Having burned your name, the only markets left - * are the ones where the law is thin - which is the same journey outward - * that smuggling forces, arrived at from the other direction. + * Whether the player's boat is at this island - the cargo lives in the + * boat, so trading needs the ship at the quay. "Here" means inside the + * island's PROTECTED space (ruled 2026-08-02): a hull a thousand blocks + * out is not a port call. Carrying the boat as an item counts - it is + * with you. * * @param player the player * @param spec the island - * @return true if the market is open to them + * @return true if they may trade here + */ + public boolean boatIsHere(Player player, IslandSpec spec) { + var hold = addon.getHoldService().active(player.getUniqueId()); + if (hold.isEmpty()) { + return false; + } + int range = addon.getSettings().getIslandProtectionRange(); + // In hand or in the pack: the boat is wherever the sailor is + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && hold.get().getUniqueId() + .equals(world.bentobox.tradewinds.travel.BoatService.boatId(stack))) { + return spec.distanceSquared(player.getLocation().getBlockX(), + player.getLocation().getBlockZ()) <= (long) range * range; + } + } + // Otherwise: wherever we last saw it + if (hold.get().getWorld() == null || hold.get().getWorld().isEmpty()) { + return false; + } + return spec.distanceSquared(hold.get().getX(), hold.get().getZ()) <= (long) range * range; + } + + /** + * Whether this island will deal with this player at all. + *

+ * A fugitive is barred from the safe bands entirely (spec principle 4): + * crime pays, into danger. */ public boolean willTradeWith(Player player, IslandSpec spec) { if (addon.getReputationService() == null || !addon.getSettings().isCrimeEnabled()) { @@ -227,14 +194,8 @@ public boolean willTradeWith(Player player, IslandSpec spec) { } /** - * The smuggler's premium. - *

- * Contraband is priced from its crafting recipe like everything else, and - * sugar's recipe price is about one currency unit - so the first playtest - * ran cargo past a customs patrol and was offered a dollar for it. The risk - * was built and the reward was not. This is the reward: what a black market - * pays over the honest price of the same goods, and the main lever for how - * profitable smuggling is. + * The smuggler's premium: what a black market pays over the honest price + * of the same goods, and the main lever for how profitable smuggling is. * * @param material the material * @return the multiplier, 1.0 for anything legal @@ -255,17 +216,30 @@ private double factor(IslandSpec spec, Material material) { // bonus apply, so the rougher the port the better it pays boolean demands = contraband || TypeEconomy.demands(spec.type()).contains(category); boolean produces = !contraband && TypeEconomy.produces(spec.type()).contains(category); - return model().economicFactor(produces, demands, spec.band().ordinal(), + double economic = model().economicFactor(produces, demands, spec.band().ordinal(), addon.getIslandDataManager().getStock(spec, category)); + // The tech tilt: high tech sells finished cheap and buys raw dear, so + // the best routes are tech DIFFERENTIALS. Contraband is exempt - the + // black market premium is its own lever and answers to nothing else. + if (!contraband) { + economic *= model().techFactor(category.isFinished(), category.isRaw(), spec.techLevel()); + } + return economic; } /** * Sell up to {@code amount} of one material from the player's hold to the - * island (Integer.MAX_VALUE = everything). + * island (Integer.MAX_VALUE = everything). No stamps, no filters: if it + * is in the hold and this port will touch it, it sells. * * @return amount sold */ public int sell(Player player, IslandSpec spec, Material material, int amount) { + if (!boatIsHere(player, spec)) { + User.getInstance(player).sendMessage("tradewinds.trade.boat-not-here"); + thud(player); + return 0; + } Optional unitPrice = playerSellsAt(spec, material); Optional vault = addon.getPlugin().getVault(); if (unitPrice.isEmpty() || vault.isEmpty() || amount <= 0) { @@ -279,9 +253,9 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { thud(player); return 0; } - int count = Math.min(addon.getHoldService().count(player, material, sellableFilter()), amount); + int count = Math.min(addon.getHoldService().count(player, material), amount); if (count <= 0) { - User.getInstance(player).sendMessage("tradewinds.trade.not-stamped"); + User.getInstance(player).sendMessage("tradewinds.trade.nothing-of-that"); thud(player); return 0; } @@ -290,23 +264,28 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { if (event.isCancelled()) { return 0; } - int removed = addon.getHoldService().remove(player, material, count, sellableFilter()); + int removed = addon.getHoldService().remove(player, material, count); double total = PriceModel.round2(removed * unitPrice.get()); vault.get().deposit(User.getInstance(player), total); addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), removed); User.getInstance(player).sendMessage("tradewinds.trade.sold", "[amount]", String.valueOf(removed), - "[material]", pretty(material), "[price]", String.format("%.2f", total)); + "[material]", pretty(material), "[price]", Money.format(addon, total)); chime(player); return removed; } /** * Buy up to {@code amount} of a material from the island into the hold, - * limited by balance and hold space. + * limited by balance and hold slots. * * @return amount bought */ public int buy(Player player, IslandSpec spec, Material material, int amount) { + if (!boatIsHere(player, spec)) { + User.getInstance(player).sendMessage("tradewinds.trade.boat-not-here"); + thud(player); + return 0; + } Optional unitPrice = playerBuysAt(spec, material); Optional vault = addon.getPlugin().getVault(); if (unitPrice.isEmpty() || vault.isEmpty() || amount <= 0) { @@ -327,9 +306,8 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { thud(player); return 0; } - // Limit by hold space: add first, pay for what fit. Bought goods are - // customs stamped - the only goods traders will buy back. - int added = addon.getHoldService().add(player, stamp(new ItemStack(material, affordable))); + // Limit by hold slots: add first, pay for what fit + int added = addon.getHoldService().add(player, material, affordable); if (added <= 0) { user.sendMessage("tradewinds.trade.no-hold-space"); thud(player); @@ -339,120 +317,121 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { vault.get().withdraw(user, total); addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), -added); user.sendMessage("tradewinds.trade.bought", "[amount]", String.valueOf(added), "[material]", - pretty(material), "[price]", String.format("%.2f", total)); + pretty(material), "[price]", Money.format(addon, total)); chime(player); return added; } /** - * Buy a cargo expander: price doubles per expander already owned, capped. + * Buy a boat from the shipwright: an UPGRADE, never a second boat. The + * replaced boat is destroyed; the hold's contents stay put and the slot + * count grows. The listing is validated against the island's tech level + * and the player's current boat, so a stale dialog cannot downgrade or + * out-tech the port. * * @return true if bought */ - public boolean buyExpander(Player player) { + public boolean buyBoat(Player player, IslandSpec spec, BoatRanks.Rank rank) { Optional vault = addon.getPlugin().getVault(); if (vault.isEmpty()) { return false; } User user = User.getInstance(player); - int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); - if (owned >= addon.getSettings().getExpanderCap()) { - user.sendMessage("tradewinds.trade.expander-cap"); + Material current = addon.getHoldService().boat(player); + boolean listed = addon.getBoatRanks().shopListing(current, spec.techLevel()).stream() + .anyMatch(r -> r.material() == rank.material()); + if (!listed) { + user.sendMessage("tradewinds.trade.boat-not-sold-here"); + thud(player); + return false; + } + double price = addon.getBoatRanks().price(rank); + // A refit is a trade-in: the yard needs the old hull in front of it. + // (Buying your FIRST boat has nothing to trade in, and must always be + // possible - it is how a sailor who lost their ship gets off the + // island.) + var owned = addon.getHoldService().active(player.getUniqueId()); + if (owned.isPresent() && !boatIsHere(player, spec)) { + user.sendMessage("tradewinds.trade.refit-needs-ship"); thud(player); return false; } - double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); if (!vault.get().has(user, price)) { user.sendMessage("tradewinds.trade.cannot-afford"); thud(player); return false; } - // An expander IS cargo space - it is carried, so it can always be - // opened and filled (a moored or pocketed chest boat cannot be) vault.get().withdraw(user, price); - player.getInventory().addItem(expanderItem()).values() - .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); - var data = addon.getPlayerDataManager().get(player.getUniqueId()); - data.setExpandersPurchased(owned + 1); - addon.getPlayerDataManager().save(player.getUniqueId()); - user.sendMessage("tradewinds.trade.expander-bought", "[price]", String.format("%.2f", price)); + if (owned.isEmpty()) { + // No ship at all: they are buying one outright, hull in hand + var fresh = addon.getBoatService().createFor(player, rank.material()); + addon.getBoatService().giveBoatItem(player, fresh); + } else { + addon.getBoatService().refit(player, owned.get(), rank.material()); + } + user.sendMessage("tradewinds.trade.boat-bought", "[material]", pretty(rank.material()), + "[slots]", String.valueOf(rank.slots()), "[price]", Money.format(addon, price)); chime(player); return true; } /** - * The cargo expander item: a lore-marked shulker box. Purchase-only - with - * no End there are no shulker shells to craft one (spec principle 7). - */ - public ItemStack expanderItem() { - // White, so it never reads as a vanilla purple shulker box - ItemStack item = new ItemStack(Material.WHITE_SHULKER_BOX); - ItemMeta meta = item.getItemMeta(); - if (meta != null) { - meta.displayName(text("tradewinds.item.expander")); - meta.lore(java.util.List.of(text("tradewinds.item.expander-lore"))); - meta.getPersistentDataContainer().set(EXPANDER_KEY, PersistentDataType.STRING, "expander"); - item.setItemMeta(meta); - } - return item; - } - - /** - * A translated component from the console locale - for item names and lore, - * which belong to the item rather than to any one viewer. - */ - private Component text(String key) { - return User.getInstance(org.bukkit.Bukkit.getConsoleSender()).getTranslationAsComponent(key, - new String[0]); - } - - /** - * A trading pouch: the first rung of cargo space, and the only hold a new - * sailor has. - */ - public ItemStack pouchItem() { - ItemStack pouch = new ItemStack(Material.BUNDLE); - ItemMeta meta = pouch.getItemMeta(); - if (meta != null) { - meta.displayName(text("tradewinds.item.pouch")); - meta.lore(java.util.List.of(text("tradewinds.item.pouch-lore"))); - pouch.setItemMeta(meta); - } - return pouch; - } - - /** - * Buy a trading pouch, up to the hold's pouch limit. + * Buy and install a cargo expander: the endgame money sink. Sold only at + * top-tech ports (the sink has a home port), installs only into a Pale + * Oak Chest Boat with a free slot, and the price doubles per expander + * already installed - the wallet is the cap. * * @return true if bought */ - /** - * What a pouch costs. Flat: pricing by how many the sailor carries would be - * defeated by dropping one before buying and picking it up afterwards. The - * max-bundles cap does the limiting instead. - */ - public double pouchPrice(Player player) { - return addon.getSettings().getPouchPrice(); + public boolean buyExpander(Player player, IslandSpec spec) { + Optional vault = addon.getPlugin().getVault(); + if (vault.isEmpty()) { + return false; + } + User user = User.getInstance(player); + if (spec.techLevel() < world.bentobox.tradewinds.galaxy.GalaxyEngine.MAX_TECH_LEVEL) { + user.sendMessage("tradewinds.trade.expander-not-sold-here"); + thud(player); + return false; + } + java.util.UUID id = player.getUniqueId(); + if (!addon.getHoldService().canInstallExpander(id)) { + user.sendMessage("tradewinds.trade.expander-needs-flagship"); + thud(player); + return false; + } + double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), + addon.getHoldService().expanderCount(id)); + if (!vault.get().has(user, price)) { + user.sendMessage("tradewinds.trade.cannot-afford"); + thud(player); + return false; + } + vault.get().withdraw(user, price); + addon.getHoldService().installExpander(id); + user.sendMessage("tradewinds.trade.expander-bought", "[price]", Money.format(addon, price)); + chime(player); + return true; } /** - * Is this sailor destitute - no cargo space, and unable to buy any? + * Is this sailor destitute - no boat, and unable to buy even the smallest? */ public boolean isDestitute(Player player) { - if (addon.getHoldService().pouchCount(player) > 0 || !addon.getHoldService().expanders(player).isEmpty()) { + if (addon.getHoldService().boat(player) != null) { return false; } + double raftPrice = addon.getBoatRanks().ladder().stream().findFirst() + .map(addon.getBoatRanks()::price).orElse(100.0); return addon.getPlugin().getVault() - .map(vault -> vault.getBalance(User.getInstance(player)) < addon.getSettings().getPouchPrice()) + .map(vault -> vault.getBalance(User.getInstance(player)) < raftPrice) .orElse(false); } /** - * The harbourmaster's charity: a sailor with no hold and no money to buy - * one cannot earn anything at all - the market needs cargo space on both - * sides of a trade - so the port gives them the bare minimum to work - * again. Charity goods are unstamped and therefore unsellable, so there is - * nothing here to farm. + * The harbourmaster's charity: a sailor with no boat has no hold, and + * with no hold they can neither earn nor leave - so the port gives them + * the barest hull that floats (a bamboo raft). * * @return true if something was given */ @@ -474,55 +453,15 @@ public boolean claimCharity(Player player) { } data.setLastCharity(System.currentTimeMillis()); addon.getPlayerDataManager().save(player.getUniqueId()); - player.getInventory().addItem(pouchItem()).values() - .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); - // A hull too, if they have neither boat nor boat item - if (!hasBoat(player)) { - player.getInventory().addItem(new ItemStack(Material.OAK_BOAT)).values() - .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); - } + Material raft = addon.getBoatRanks().ladder().stream().findFirst() + .map(BoatRanks.Rank::material).orElse(Material.BAMBOO_RAFT); + var hold = addon.getBoatService().createFor(player, raft); + addon.getBoatService().giveBoatItem(player, hold); user.sendMessage("tradewinds.trade.charity-given"); chime(player); return true; } - private boolean hasBoat(Player player) { - if (player.getVehicle() instanceof org.bukkit.entity.Boat) { - return true; - } - for (ItemStack stack : player.getInventory().getContents()) { - if (stack != null && stack.getType().name().endsWith("_BOAT")) { - return true; - } - } - return false; - } - - public boolean buyPouch(Player player) { - Optional vault = addon.getPlugin().getVault(); - if (vault.isEmpty()) { - return false; - } - User user = User.getInstance(player); - if (addon.getHoldService().pouchCount(player) >= addon.getSettings().getMaxBundles()) { - user.sendMessage("tradewinds.trade.pouch-cap"); - thud(player); - return false; - } - double price = pouchPrice(player); - if (!vault.get().has(user, price)) { - user.sendMessage("tradewinds.trade.cannot-afford"); - thud(player); - return false; - } - vault.get().withdraw(user, price); - player.getInventory().addItem(pouchItem()).values() - .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); - user.sendMessage("tradewinds.trade.pouch-bought", "[price]", String.format("%.2f", price)); - chime(player); - return true; - } - static String pretty(Material material) { return PriceEngine.prettify(material.name()); } diff --git a/src/main/java/world/bentobox/tradewinds/economy/Money.java b/src/main/java/world/bentobox/tradewinds/economy/Money.java new file mode 100644 index 0000000..79cadb6 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/economy/Money.java @@ -0,0 +1,35 @@ +package world.bentobox.tradewinds.economy; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Renders an amount of money the way THIS server's economy renders it. + *

+ * Vault's {@code Economy.format(double)} knows the server's currency name and + * how many decimal places it keeps ({@code fractionalDigits()}); an admin who + * runs a whole-coin economy has said so, and printing "$0.97" over the top of + * that ignores them. Every price in TradeWinds used to be hand-formatted to + * two decimals, which is where the ragged market charts came from. + *

+ * Without Vault there is nothing to ask, so the fallback prints whole units - + * which is what TradeWinds' own prices now are (see + * {@link PriceModel#playerBuysAt}). + * + * @author tastybento + */ +public final class Money { + + private Money() { + // Static use only + } + + /** + * @param addon the addon + * @param amount an amount of money + * @return the amount as this server's economy would write it + */ + public static String format(TradeWinds addon, double amount) { + return addon.getPlugin().getVault().map(vault -> vault.format(amount)) + .orElseGet(() -> String.format("%.0f", amount)); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java b/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java index fbe4fb1..a359727 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java +++ b/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java @@ -17,11 +17,40 @@ * @param driftScale stock units for full drift swing * @param driftMin lower clamp of the drift factor * @param driftMax upper clamp of the drift factor + * @param techPriceStep price tilt per tech-level step from 4: high tech sells + * finished goods cheap and buys raw dear, low tech the inverse * * @author tastybento */ public record PriceModel(double produceFactor, double demandFactor, double bandDemandBonus, double buySpread, - double sellSpread, int driftScale, double driftMin, double driftMax) { + double sellSpread, int driftScale, double driftMin, double driftMax, double techPriceStep) { + + /** + * Convenience constructor without the tech tilt (tests, tech-neutral use). + */ + public PriceModel(double produceFactor, double demandFactor, double bandDemandBonus, double buySpread, + double sellSpread, int driftScale, double driftMin, double driftMax) { + this(produceFactor, demandFactor, bandDemandBonus, buySpread, sellSpread, driftScale, driftMin, driftMax, + 0.0); + } + + /** + * The tech tilt for a good at an island of the given tech level. Finished + * goods get cheaper as tech rises, raw goods dearer; TL4 is neutral. + * Categories that are neither (gems, luxuries, misc) are untouched. + * + * @param finished the category is finished goods + * @param raw the category is raw goods + * @param techLevel the island's tech level (1-7) + * @return multiplier around 1 + */ + public double techFactor(boolean finished, boolean raw, int techLevel) { + if (finished == raw) { + return 1.0; + } + double shift = techPriceStep * (techLevel - 4); + return Math.max(0.1, finished ? 1.0 - shift : 1.0 + shift); + } /** * Stock drift: positive stock (players sold a lot here) depresses prices, @@ -54,17 +83,28 @@ public double economicFactor(boolean produces, boolean demands, int bandOrdinal, } /** - * What a player pays the island per unit. + * What a player pays the island per unit: rounded UP to a whole unit of + * currency, and never free. + *

+ * Prices are whole numbers (adopted 2026-08-03): cents made every chart + * ragged - "Oak Log x1 - $0.97" - and Minecraft economies are usually + * counted in whole coins. The rounding DIRECTION is the load-bearing part: + * buying rounds up and selling rounds down, so the buy/sell spread can + * never close. Rounding both to nearest would let a same-island round trip + * break even or profit on some prices, which is free money. */ public double playerBuysAt(double base, double economicFactor) { - return round2(base * economicFactor * buySpread); + return Math.max(1, Math.ceil(base * economicFactor * buySpread)); } /** - * What the island pays a player per unit. + * What the island pays a player per unit: rounded DOWN to a whole unit of + * currency (see {@link #playerBuysAt} for why the direction matters). A + * good can be worth nothing here - that is a market saying it does not + * want it. */ public double playerSellsAt(double base, double economicFactor) { - return round2(base * economicFactor * sellSpread); + return Math.max(0, Math.floor(base * economicFactor * sellSpread)); } /** diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java index 0e0a2e7..24ecb1b 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java @@ -14,6 +14,22 @@ public enum TradeCategory { CROPS, FOOD, FISH, WOOD, STONE, ORES, METALS, GEMS, LUXURY, MISC; + /** + * Raw goods - what low-tech islands live on. High-tech ports pay over the + * odds for these (they feed the works), low-tech ports sell them cheap. + */ + public boolean isRaw() { + return this == ORES || this == CROPS || this == WOOD || this == FISH || this == STONE; + } + + /** + * Finished goods - what high-tech islands produce. High-tech ports sell + * these cheap, low-tech ports pay dearly for them. + */ + public boolean isFinished() { + return this == METALS || this == FOOD; + } + /** * Classify a material by name heuristics. MISC means "no special island * affinity" - still tradeable at base price everywhere. diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 5f6f614..ce0a8c9 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -56,6 +56,11 @@ public void openMain(Player player, IslandSpec spec) { User.getInstance(player).sendMessage("tradewinds.trade.barred"); return; } + // The cargo is IN the boat, so buying and selling need the ship at + // the quay - but the OUTFITTER and the SHIPWRIGHT must always be + // open, or a sailor who has lost their boat can never get another + // and is stranded on the island for good (playtest 2026-08-02). + boolean boatHere = addon.getMarketService().boatIsHere(player, spec); // Too poor in fuel to warp anywhere from here? Then the most useful // thing this screen can do is point at where the fuel is sold. An // action bar fades; a button that says "buy fuel here" does not. @@ -64,10 +69,10 @@ public void openMain(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); // No sell button when the hold has nothing this island pays for - if (!sellOffers(player, spec).isEmpty()) { + if (boatHere && !sellOffers(player, spec).isEmpty()) { buttons.add(button(player, "market.sell", "market.sell-tooltip", () -> openSell(player, spec))); } - if (!addon.getMarketService().saleCatalog(spec).isEmpty()) { + if (boatHere && !addon.getMarketService().saleCatalog(spec).isEmpty()) { buttons.add(button(player, fuelInTradeCatalog ? "market.buy-fuel" : "market.buy", fuelInTradeCatalog ? "market.buy-fuel-tooltip" : "market.buy-tooltip", () -> openBuy(player, spec, addon.getMarketService().saleCatalog(spec), "buying"))); @@ -81,10 +86,14 @@ public void openMain(Player player, IslandSpec spec) { buttons.add(button(player, "market.shipwright", "market.shipwright-tooltip", () -> openShipwright(player, spec))); List body = new ArrayList<>(List.of( - ui(player, "market.subtitle", "[type]", spec.type().name(), "[band]", + ui(player, "market.subtitle", "[type]", spec.type().name(), "[tech]", + String.valueOf(spec.techLevel()), "[band]", User.getInstance(player).getTranslation(spec.band().getLocaleKey())), statusLine(player))); - if (lowFuel) { + if (!boatHere) { + body.add(ui(player, "market.no-ship-here", NO_VARS)); + } + if (lowFuel && boatHere) { body.add(ui(player, "market.low-fuel", NO_VARS)); } show(player, ui(player, "market.title", "[name]", spec.name()), body, buttons, closeButton(player), 1); @@ -123,14 +132,14 @@ public void openSell(Player player, IslandSpec spec) { for (SellOffer offer : offers.subList(0, Math.min(MAX_ROWS, offers.size()))) { String name = MarketService.pretty(offer.material()); Component each = ui(player, "market.sell-tooltip-each", "[price]", - String.format("%.2f", offer.unitPrice())); + Money.format(addon, offer.unitPrice())); buttons.add(button(ui(player, "market.sell-one", "[material]", name), each, () -> sellThenReopen(player, spec, offer.material(), 1))); buttons.add(button(ui(player, "market.sell-batch", "[material]", name, "[amount]", String.valueOf(MID_BATCH)), each, () -> sellThenReopen(player, spec, offer.material(), MID_BATCH))); buttons.add(button(ui(player, "market.sell-all", "[amount]", String.valueOf(offer.amount()), - "[price]", String.format("%.2f", offer.total())), each, + "[price]", Money.format(addon, offer.total())), each, () -> sellThenReopen(player, spec, offer.material(), Integer.MAX_VALUE))); } List body = new ArrayList<>(List.of(ui(player, "market.selling-body", NO_VARS), @@ -156,15 +165,15 @@ public void openBuy(Player player, IslandSpec spec, List catalog, Stri Optional price = addon.getMarketService().playerBuysAt(spec, material); price.ifPresent(unit -> { String name = MarketService.pretty(material); - Component each = ui(player, "market.buy-tooltip-each", "[price]", String.format("%.2f", unit)); + Component each = ui(player, "market.buy-tooltip-each", "[price]", Money.format(addon, unit)); buttons.add(button(ui(player, "market.buy-one", "[material]", name, "[price]", - String.format("%.2f", unit)), each, + Money.format(addon, unit)), each, () -> buyThenReopen(player, spec, material, 1, catalog, page))); buttons.add(button(ui(player, "market.buy-batch", "[amount]", String.valueOf(MID_BATCH), - "[price]", String.format("%.2f", unit * MID_BATCH)), each, + "[price]", Money.format(addon, unit * MID_BATCH)), each, () -> buyThenReopen(player, spec, material, MID_BATCH, catalog, page))); buttons.add(button(ui(player, "market.buy-batch", "[amount]", String.valueOf(BIG_BATCH), - "[price]", String.format("%.2f", unit * BIG_BATCH)), each, + "[price]", Money.format(addon, unit * BIG_BATCH)), each, () -> buyThenReopen(player, spec, material, BIG_BATCH, catalog, page))); }); } @@ -194,13 +203,13 @@ public void openOutfitter(Player player, IslandSpec spec) { for (Material material : addon.getMarketService().outfitterCatalog(spec)) { addon.getMarketService().playerBuysAt(spec, material).ifPresent(unit -> { String name = MarketService.pretty(material); - Component each = ui(player, "market.outfit-tooltip", "[price]", String.format("%.2f", unit)); + Component each = ui(player, "market.outfit-tooltip", "[price]", Money.format(addon, unit)); buttons.add(button(ui(player, "market.outfit-one", "[material]", name, "[price]", - String.format("%.2f", unit)), each, + Money.format(addon, unit)), each, () -> outfitThenReopen(player, spec, material, 1))); if (new org.bukkit.inventory.ItemStack(material).getMaxStackSize() > 1) { buttons.add(button(ui(player, "market.outfit-batch", "[amount]", String.valueOf(MID_BATCH), - "[price]", String.format("%.2f", unit * MID_BATCH)), each, + "[price]", Money.format(addon, unit * MID_BATCH)), each, () -> outfitThenReopen(player, spec, material, MID_BATCH))); } }); @@ -221,44 +230,41 @@ private void outfitThenReopen(Player player, IslandSpec spec, Material material, } /** - * The shipwright's slipway: hulls to your inventory, expanders to your - * hold. The cargo progression lives here: boat -> chest boat -> expanders. + * The shipwright's slipway: the boat ladder. Only boats BIGGER than yours + * are listed (an upgrade destroys the old boat; contents stay put), and + * only up to what this island's tech can build. Crafting bypasses all of + * this - the shop is the money route, rare wood is the exploration route. */ public void openShipwright(Player player, IslandSpec spec) { List buttons = new ArrayList<>(); - for (Material hull : addon.getMarketService().shipwrightCatalog()) { - addon.getMarketService().playerBuysAt(spec, hull).ifPresent(unit -> buttons.add(button( - ui(player, "market.hull", "[material]", MarketService.pretty(hull), "[price]", - String.format("%.2f", unit)), - ui(player, "market.hull-tooltip", NO_VARS), + Material current = addon.getHoldService().boat(player); + for (world.bentobox.tradewinds.travel.BoatRanks.Rank rank : addon.getBoatRanks() + .shopListing(current, spec.techLevel())) { + double price = addon.getBoatRanks().price(rank); + buttons.add(button( + ui(player, "market.hull", "[material]", MarketService.pretty(rank.material()), "[price]", + Money.format(addon, price)), + ui(player, "market.hull-tooltip", "[slots]", String.valueOf(rank.slots())), () -> { - addon.getMarketService().buyToInventory(player, spec, hull, 1); - openShipwright(player, spec); - }))); - } - int pouches = addon.getHoldService().pouchCount(player); - int maxPouches = addon.getSettings().getMaxBundles(); - if (pouches < maxPouches) { - buttons.add(button(ui(player, "market.pouch", "[price]", - String.format("%.0f", addon.getMarketService().pouchPrice(player))), - ui(player, "market.pouch-tooltip", "[owned]", String.valueOf(pouches), "[cap]", - String.valueOf(maxPouches)), - () -> { - addon.getMarketService().buyPouch(player); + addon.getMarketService().buyBoat(player, spec, rank); openShipwright(player, spec); })); } - int owned = addon.getPlayerDataManager().get(player.getUniqueId()).getExpandersPurchased(); - if (owned < addon.getSettings().getExpanderCap()) { - double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), owned); - buttons.add(button(ui(player, "market.expander", "[price]", String.format("%.0f", price)), - ui(player, "market.expander-tooltip", "[owned]", String.valueOf(owned), "[cap]", - String.valueOf(addon.getSettings().getExpanderCap())), + // Expanders: the endgame sink, sold only where the tech tops out + if (spec.techLevel() >= world.bentobox.tradewinds.galaxy.GalaxyEngine.MAX_TECH_LEVEL) { + int installed = addon.getHoldService().expanderCount(player.getUniqueId()); + double price = PriceModel.expanderPrice(addon.getSettings().getExpanderBasePrice(), installed); + buttons.add(button(ui(player, "market.expander", "[price]", Money.format(addon, price)), + ui(player, "market.expander-tooltip", "[owned]", String.valueOf(installed)), () -> { - addon.getMarketService().buyExpander(player); + addon.getMarketService().buyExpander(player, spec); openShipwright(player, spec); })); } + if (buttons.isEmpty()) { + // Nothing on the slipway: the sailor has out-teched this port + buttons.add(button(player, "market.no-hulls", "market.no-hulls-tooltip", () -> openMain(player, spec))); + } if (addon.getMarketService().isDestitute(player)) { buttons.add(button(player, "market.charity", "market.charity-tooltip", () -> { @@ -271,6 +277,59 @@ public void openShipwright(Player player, IslandSpec spec) { backButton(player, spec), 1); } + /** + * The confirmation every boat capture gets: taking this hull abandons + * your own, cargo and all, wherever it lies. Yes/no, no default. + * + * @param player the player + * @param taking pretty name of the boat they would take + * @param leaving pretty name of the boat they would abandon + * @param onConfirm what to do if they accept + */ + public void confirmBoatCapture(Player player, String taking, String leaving, Runnable onConfirm) { + confirmBoatFound(player, taking, leaving, null, onConfirm, null); + } + + /** + * What to do with a hull you have come across (ruled 2026-08-02). Taking + * it abandons your own boat where it lies; moving its cargo across is + * offered ONLY when your own boat is close enough to load - cargo is not + * teleported across the ocean. + * + * @param player the player + * @param taking pretty name of the hull found + * @param leaving pretty name of their own boat, or null if they have none + * @param cargo a description of what it carries, or null if empty + * @param onTake take the hull + * @param onTransfer move the cargo into their own boat, or null if their + * boat is out of range (or the hull is empty) + */ + public void confirmBoatFound(Player player, String taking, String leaving, String cargo, Runnable onTake, + Runnable onTransfer) { + List buttons = new ArrayList<>(); + buttons.add(button(ui(player, "capture.confirm", "[material]", taking), + ui(player, "capture.confirm-tooltip", NO_VARS), onTake)); + if (onTransfer != null) { + buttons.add(button(ui(player, "capture.transfer", NO_VARS), + ui(player, "capture.transfer-tooltip", NO_VARS), onTransfer)); + } + List body = new ArrayList<>(); + if (leaving == null) { + body.add(ui(player, "capture.body-no-boat", "[taking]", taking)); + } else { + body.add(ui(player, "capture.body", "[taking]", taking, "[leaving]", leaving)); + if (onTransfer == null && cargo != null) { + // Say WHY the cargo option is missing, or it reads as a bug + body.add(ui(player, "capture.body-too-far", "[leaving]", leaving)); + } + } + if (cargo != null) { + body.add(ui(player, "capture.body-cargo", "[cargo]", cargo)); + } + show(player, ui(player, "capture.title", "[material]", taking), body, buttons, + ActionButton.builder(ui(player, "capture.cancel", NO_VARS)).width(300).build(), 1); + } + /** * One sellable line: what the hold has and what this island pays. Package * visible for tests. @@ -280,9 +339,9 @@ record SellOffer(Material material, int amount, double unitPrice, double total) List sellOffers(Player player, IslandSpec spec) { List offers = new ArrayList<>(); - // Traders only buy customs-stamped goods (plus the illegal exceptions) - for (Map.Entry entry : addon.getHoldService() - .contents(player, addon.getMarketService().sellableFilter()).entrySet()) { + // No stamps: everything aboard (expanders included) is sellable + // wherever a price and the port's own rules (contraband bands) allow + for (Map.Entry entry : addon.getHoldService().tradeContents(player).entrySet()) { // Do not quote a price for something this port will refuse at the // counter: the sell page used to offer a dollar for contraband that // a safe island would then decline, which reads as a broken market @@ -311,8 +370,9 @@ private Component ui(Player player, String key, String... variables) { private Component statusLine(Player player) { double balance = addon.getPlugin().getVault().map(v -> v.getBalance(User.getInstance(player))).orElse(0.0); - return ui(player, "market.status", "[balance]", String.format("%.2f", balance), "[space]", - String.valueOf(addon.getHoldService().freeSpace(player))); + return ui(player, "market.status", "[balance]", Money.format(addon, balance), "[space]", + addon.getHoldService().slotsUsed(player.getUniqueId()) + "/" + + addon.getHoldService().capacitySlots(player)); } private ActionButton backButton(Player player, IslandSpec spec) { diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java index 25d6418..03d6def 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java @@ -48,8 +48,7 @@ public void onEncounterDeath(EntityDeathEvent event) { addon.logError("Unknown material in encounters.booty-table: " + entry); return; } - // Stamped, so it can be sold: salvage has papers - ItemStack booty = addon.getMarketService().stamp(new ItemStack(material)); - event.getDrops().add(booty); + // Plain loot: stow it in the hold and any port will buy it + event.getDrops().add(new ItemStack(material)); } } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java index 5ffdfa5..ff291a1 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/GalaxyEngine.java @@ -36,6 +36,7 @@ public class GalaxyEngine { private static final long SALT_JITTER_X = 0x7177E12AL; private static final long SALT_JITTER_Z = 0x7177E12BL; private static final long SALT_TYPE = 0x7E9BE001L; + private static final long SALT_TECH = 0x7EC81EE1L; private static final long SALT_BAND = 0xBA9D0001L; private static final long SALT_BIOME = 0xB10E0001L; private static final long SALT_NAME = 0x9A3E0001L; @@ -80,13 +81,64 @@ public class GalaxyEngine { */ private static final double DEEP_WATER_FRACTION = 0.6; - /** Vanilla biomes wild islets draw from - Minecraft-stuff land. */ - private static final List WILD_BIOMES = List.of("minecraft:plains", "minecraft:forest", - "minecraft:birch_forest", "minecraft:jungle", "minecraft:savanna", "minecraft:swamp", - "minecraft:flower_forest", "minecraft:dark_forest"); + /** + * Vanilla biomes wild islets draw from - Minecraft-stuff land - grouped by + * the sea temperature at the islet (index order matches + * {@link #OCEAN_BIOMES}: frozen through warm). A snowfield rises out of + * frozen water and a jungle out of warm, and between the five bands every + * generatable overworld land biome can turn up somewhere at sea - a cherry + * grove sandbar and a pale garden islet are rare finds, not absences. + * (Cave, river, ocean, Nether and End biomes are deliberately not islet + * material.) + */ + static final List> WILD_BIOMES = List.of( + // Frozen seas + List.of("minecraft:snowy_plains", "minecraft:snowy_taiga", "minecraft:ice_spikes", + "minecraft:snowy_slopes", "minecraft:grove", "minecraft:frozen_peaks"), + // Cold + List.of("minecraft:taiga", "minecraft:old_growth_pine_taiga", "minecraft:old_growth_spruce_taiga", + "minecraft:windswept_hills", "minecraft:windswept_forest", + "minecraft:windswept_gravelly_hills", "minecraft:jagged_peaks", "minecraft:stony_shore"), + // Temperate + List.of("minecraft:plains", "minecraft:sunflower_plains", "minecraft:forest", + "minecraft:birch_forest", "minecraft:old_growth_birch_forest", "minecraft:flower_forest", + "minecraft:dark_forest", "minecraft:pale_garden", "minecraft:meadow", + "minecraft:cherry_grove", "minecraft:swamp", "minecraft:stony_peaks"), + // Lukewarm + List.of("minecraft:savanna", "minecraft:savanna_plateau", "minecraft:windswept_savanna", + "minecraft:sparse_jungle", "minecraft:jungle", "minecraft:wooded_badlands"), + // Warm + List.of("minecraft:desert", "minecraft:badlands", "minecraft:eroded_badlands", + "minecraft:bamboo_jungle", "minecraft:mangrove_swamp")); /** The rare islet biome - no hostile spawns, mycelium, mooshrooms. */ public static final String MUSHROOM_BIOME = "minecraft:mushroom_fields"; + + /** + * Ground truth, literally: what the top of a land column is made of, by the + * biome that governs the column. Anything unlisted stands on grass. Applies + * to trading islands and islets alike - a desert port is a sand island, not + * a lawn with a desert sky. + */ + private static final Map BIOME_SURFACES = Map.ofEntries( + Map.entry("minecraft:desert", SurfaceKind.SAND), + Map.entry("minecraft:beach", SurfaceKind.SAND), + Map.entry("minecraft:snowy_beach", SurfaceKind.SAND), + Map.entry("minecraft:badlands", SurfaceKind.RED_SAND), + Map.entry("minecraft:eroded_badlands", SurfaceKind.RED_SAND), + Map.entry("minecraft:wooded_badlands", SurfaceKind.RED_SAND), + Map.entry("minecraft:old_growth_pine_taiga", SurfaceKind.PODZOL), + Map.entry("minecraft:old_growth_spruce_taiga", SurfaceKind.PODZOL), + Map.entry("minecraft:stony_shore", SurfaceKind.STONE), + Map.entry("minecraft:stony_peaks", SurfaceKind.STONE), + Map.entry("minecraft:jagged_peaks", SurfaceKind.STONE), + Map.entry("minecraft:windswept_gravelly_hills", SurfaceKind.GRAVEL), + Map.entry("minecraft:mangrove_swamp", SurfaceKind.MUD), + Map.entry("minecraft:grove", SurfaceKind.SNOW), + Map.entry("minecraft:snowy_slopes", SurfaceKind.SNOW), + Map.entry("minecraft:frozen_peaks", SurfaceKind.SNOW), + Map.entry("minecraft:ice_spikes", SurfaceKind.SNOW), + Map.entry(MUSHROOM_BIOME, SurfaceKind.MYCELIUM)); /** Sandy fringes: where beached shipwrecks and buried treasure belong. */ private static final String BEACH_BIOME = "minecraft:beach"; private static final String SNOWY_BEACH_BIOME = "minecraft:snowy_beach"; @@ -112,15 +164,66 @@ public class GalaxyEngine { /** Dock deck sits this many blocks above sea level. */ public static final int DOCK_RISE = 1; + /** Highest tech level an island can roll. */ + public static final int MAX_TECH_LEVEL = 7; + /** The tech level the starter cluster guarantees at least one of. */ + private static final int STARTER_TECH_FLOOR = 3; + + /** + * Base tech level per island type - industry and luxury skew high, farms + * and fisheries low. The seeded wobble of +/-2 around these means an + * "Advanced Agricultural" island exists, it is just uncommon. + */ + private static final Map TECH_BASE = Map.of( + IslandType.AGRICULTURAL, 2, + IslandType.FISHING, 2, + IslandType.FOREST, 3, + IslandType.FROZEN, 3, + IslandType.MINING, 4, + IslandType.INDUSTRIAL, 5, + IslandType.LUXURY, 5); + private final GalaxyConfig config; private final Set starterCells; private final Map> cache = new ConcurrentHashMap<>(); private final Seabed seabed; + /** The starter cell whose island is lifted to the tech floor, if any needs it. */ + private final long boostedTechCell; public GalaxyEngine(GalaxyConfig config) { this.config = config; this.starterCells = computeStarterCells(); this.seabed = new Seabed(config.seed(), config.seaLevel(), config.seabed()); + this.boostedTechCell = computeBoostedTechCell(); + } + + /** + * The starter cluster must hold at least one island of the tech floor + * (spec 2.2), or a fresh sailor could be walled off from every useful + * shop by seed luck. If no starter cell rolls it naturally, the cell with + * the highest natural roll (ties by key order) is lifted to the floor - + * deterministically, in the constructor, so the answer never depends on + * query order. + */ + private long computeBoostedTechCell() { + long best = Long.MIN_VALUE; + int bestRoll = -1; + for (long key : starterCells.stream().sorted().toList()) { + int cellX = (int) (key >> 32); + int cellZ = (int) key; + IslandType type = cellX == 0 && cellZ == 0 && config.spawnIslandType() != null + ? config.spawnIslandType() + : rollType(cellX, cellZ); + int roll = rollTech(cellX, cellZ, type); + if (roll >= STARTER_TECH_FLOOR) { + return Long.MIN_VALUE; // Seed luck already provides one + } + if (roll > bestRoll) { + bestRoll = roll; + best = key; + } + } + return best; } /** @@ -198,7 +301,7 @@ private Optional computeIsland(int cellX, int cellZ) { if (cellX == 0 && cellZ == 0) { IslandType type = config.spawnIslandType() != null ? config.spawnIslandType() : rollType(0, 0); return Optional.of(new IslandSpec(0, 0, 0, 0, type, SecurityBand.SAFE, rollBiome(0, 0, type), - SPAWN_NAME)); + SPAWN_NAME, techLevel(0, 0, type))); } boolean starter = starterCells.contains(cellKey(cellX, cellZ)); if (!starter @@ -223,7 +326,27 @@ private Optional computeIsland(int cellX, int cellZ) { SecurityBand band = starter ? SecurityBand.SAFE : rollBand(cellX, cellZ, centerX, centerZ); String biome = rollBiome(cellX, cellZ, type); String name = NameGenerator.name(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_NAME)); - return Optional.of(new IslandSpec(cellX, cellZ, centerX, centerZ, type, band, biome, name)); + return Optional.of(new IslandSpec(cellX, cellZ, centerX, centerZ, type, band, biome, name, + techLevel(cellX, cellZ, type))); + } + + /** + * The natural tech roll: the type's base plus a seeded wobble of +/-2, + * clamped to 1..{@value #MAX_TECH_LEVEL}. + */ + private int rollTech(int cellX, int cellZ, IslandType type) { + int base = TECH_BASE.getOrDefault(type, 3); + int wobble = (int) Math.floorMod(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_TECH), 5) - 2; + return Math.clamp(base + wobble, 1, MAX_TECH_LEVEL); + } + + /** + * An island's tech level: the natural roll, lifted to the starter floor + * for the one boosted starter cell (if the cluster needed one). + */ + private int techLevel(int cellX, int cellZ, IslandType type) { + int rolled = rollTech(cellX, cellZ, type); + return cellKey(cellX, cellZ) == boostedTechCell ? Math.max(rolled, STARTER_TECH_FLOOR) : rolled; } private IslandType rollType(int cellX, int cellZ) { @@ -442,20 +565,22 @@ public Optional wildIsletInCell(int cellX, int cellZ) { return Optional.empty(); } } - return Optional.of(new Islet(x, z, radius, isletBiome(cellX, cellZ))); + return Optional.of(new Islet(x, z, radius, isletBiome(cellX, cellZ, x, z))); } /** - * A wild islet's whole-island biome (seeded from its cell). Mostly ordinary - * vanilla land; rarely, mushroom fields. + * A wild islet's whole-island biome: seeded from its cell, drawn from the + * band matching the sea temperature at its center - so the land suits the + * water it stands in. Rarely, mushroom fields instead. */ - private String isletBiome(int cellX, int cellZ) { + private String isletBiome(int cellX, int cellZ, int centerX, int centerZ) { if (Hashing.toUnit(Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_MUSHROOM)) < config.mushroomIsletChance()) { return MUSHROOM_BIOME; } + List band = WILD_BIOMES.get(oceanTemperatureIndex(centerX, centerZ)); long hash = Hashing.cellHash(config.seed(), cellX, cellZ, SALT_WILD_BIOME); - return WILD_BIOMES.get((int) Math.floorMod(hash, WILD_BIOMES.size())); + return band.get((int) Math.floorMod(hash, band.size())); } /** @@ -688,23 +813,20 @@ private static double shelfTaper(double distance, int radius) { } /** - * What the top block of a land column should be. Islets get a sandy fringe - * at the waterline (and mycelium all over, if they are mushroom islands); - * everything else is ordinary grass. + * What the top block of a land column should be: whatever suits the biome + * that governs the column. The biome already knows everything - islet or + * island, beach fringe at the waterline, mushroom fields - so the surface + * simply follows it: sand under a desert, mud under a mangrove swamp, red + * sand in the badlands, snowpack on the slopes, grass for everything else. * * @param blockX block x * @param blockZ block z * @return the surface kind */ public SurfaceKind surfaceKindAt(int blockX, int blockZ) { - Optional islet = isletAt(blockX, blockZ); - if (islet.isEmpty()) { - return SurfaceKind.GRASS; - } - if (islet.get().isMushroom()) { - return SurfaceKind.MYCELIUM; - } - return isShoreAt(blockX, blockZ) ? SurfaceKind.SAND : SurfaceKind.GRASS; + return biomeKeyAt(blockX, blockZ) + .map(key -> BIOME_SURFACES.getOrDefault(key, SurfaceKind.GRASS)) + .orElse(SurfaceKind.GRASS); } /** @@ -725,7 +847,8 @@ public static List oceanBiomes() { * @return the islet biomes */ public static List isletBiomes() { - List all = new ArrayList<>(WILD_BIOMES); + List all = new ArrayList<>(); + WILD_BIOMES.forEach(band -> band.stream().filter(k -> !all.contains(k)).forEach(all::add)); all.add(MUSHROOM_BIOME); all.add(BEACH_BIOME); all.add(SNOWY_BEACH_BIOME); diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/IslandSpec.java b/src/main/java/world/bentobox/tradewinds/galaxy/IslandSpec.java index 0984ef7..382994c 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/IslandSpec.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/IslandSpec.java @@ -12,11 +12,23 @@ * @param band security band * @param biomeKey namespaced biome key for the whole island * @param name procedural island name + * @param techLevel 1-7; gates what the shops SELL (boat ranks, expanders) and + * tilts prices (high tech sells finished cheap, buys raw dear) - never + * docking, riding or crafting * * @author tastybento */ public record IslandSpec(int cellX, int cellZ, int centerX, int centerZ, IslandType type, SecurityBand band, - String biomeKey, String name) { + String biomeKey, String name, int techLevel) { + + /** + * Convenience constructor for callers (mostly tests) that do not care + * about tech: a plain TL1 island. + */ + public IslandSpec(int cellX, int cellZ, int centerX, int centerZ, IslandType type, SecurityBand band, + String biomeKey, String name) { + this(cellX, cellZ, centerX, centerZ, type, band, biomeKey, name, 1); + } /** * Squared distance from a block position to this island's center. diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/IsletStructures.java b/src/main/java/world/bentobox/tradewinds/galaxy/IsletStructures.java new file mode 100644 index 0000000..cf1d0a1 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/IsletStructures.java @@ -0,0 +1,181 @@ +package world.bentobox.tradewinds.galaxy; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Which small vanilla structure - if any - stands at the heart of a wild + * islet. Pure selection, no Bukkit: the generator loads the chosen template + * by name and places it; this class only decides, deterministically from + * (seed, islet), so the same galaxy grows the same ruins everywhere. + *

+ * The menu is deliberately restricted to vanilla's single-piece NBT templates + * (fossils, igloo tops, ruined portals, pillager camp props) - jigsaw-built + * structures would leave connector blocks behind if placed raw. Each biome + * family gets what belongs there: an igloo on the snowfields, bones in the + * desert, a sunken portal in the jungle, an abandoned camp in the woods. + * Mushroom islets and pale gardens stay empty on purpose: there the biome + * itself is the find. + * + * @author tastybento + */ +public final class IsletStructures { + + private static final long SALT_ROLL = 0x157C7BE0L; + private static final long SALT_CATEGORY = 0x157C7BE1L; + private static final long SALT_VARIANT = 0x157C7BE2L; + + /** + * One structure to place: a vanilla template key (under the minecraft + * namespace), how many blocks to sink its base below the surface (bones + * lie half-buried, tents sit on top), and what its JIGSAW blocks must + * become. + *

+ * Several vanilla templates carry jigsaw connector blocks. Vanilla's own + * generator swaps each for its {@code final_state} as it assembles the + * structure; pasting the template raw leaves them standing, which is + * exactly the row of glowing jigsaws found on an islet in the 2026-08-02 + * playtest. The right replacement differs per template - camps want air, + * most ruined portals want netherrack - so it is read from the template's + * own NBT and recorded here rather than guessed at runtime. + * + * @param template the template key, e.g. {@code fossil/spine_1} + * @param sink blocks below the surface to set the template's base + * @param jigsawFill what a jigsaw block in this template becomes + */ + public record Placement(String template, int sink, String jigsawFill) { + + /** A template with no jigsaws, or one whose connectors vanish. */ + public Placement(String template, int sink) { + this(template, sink, "AIR"); + } + } + + /** An igloo, floor flush with the snow. */ + private static final List IGLOO = List.of(new Placement("igloo/top", 1)); + + /** Old bones, half-buried where the ground is soft. */ + private static final List FOSSILS = List.of( + new Placement("fossil/spine_1", 3), new Placement("fossil/spine_2", 3), + new Placement("fossil/spine_3", 3), new Placement("fossil/spine_4", 3), + new Placement("fossil/skull_1", 3), new Placement("fossil/skull_2", 3), + new Placement("fossil/skull_3", 3), new Placement("fossil/skull_4", 3)); + + /** A ruined nether portal, settled a couple of blocks into the ground. */ + private static final List PORTALS = List.of( + new Placement("ruined_portal/portal_1", 2, "NETHERRACK"), + new Placement("ruined_portal/portal_2", 2, "NETHERRACK"), + new Placement("ruined_portal/portal_3", 2), + new Placement("ruined_portal/portal_4", 2, "NETHERRACK"), + new Placement("ruined_portal/portal_5", 2, "NETHERRACK"), + new Placement("ruined_portal/portal_6", 2), new Placement("ruined_portal/portal_7", 2), + new Placement("ruined_portal/portal_8", 2), new Placement("ruined_portal/portal_9", 2), + new Placement("ruined_portal/portal_10", 2)); + + /** An abandoned camp: tents, a log pile, a practice range. */ + private static final List CAMP = List.of( + new Placement("pillager_outpost/feature_tent1", 0), + new Placement("pillager_outpost/feature_tent2", 0), + new Placement("pillager_outpost/feature_logs", 0), + new Placement("pillager_outpost/feature_targets", 0)); + + /** Nothing, ever - the biome is its own event. */ + private static final List> NONE = List.of(); + + private static final List> SNOWY = List.of(IGLOO); + private static final List> DRYLANDS = List.of(FOSSILS, PORTALS); + private static final List> WOODS = List.of(CAMP, PORTALS); + private static final List> JUNGLES = List.of(PORTALS, FOSSILS); + private static final List> FELLS = List.of(FOSSILS, PORTALS); + + /** + * Category tables per islet biome. Two-level so a camp is as likely as a + * portal even though there are ten portal variants: the first roll picks + * the category, the second the variant. + */ + private static final Map>> TABLES = Map.ofEntries( + // Frozen seas + Map.entry("minecraft:snowy_plains", SNOWY), + Map.entry("minecraft:snowy_taiga", SNOWY), + Map.entry("minecraft:ice_spikes", SNOWY), + Map.entry("minecraft:snowy_slopes", SNOWY), + Map.entry("minecraft:grove", SNOWY), + Map.entry("minecraft:frozen_peaks", SNOWY), + // Cold + Map.entry("minecraft:taiga", WOODS), + Map.entry("minecraft:old_growth_pine_taiga", WOODS), + Map.entry("minecraft:old_growth_spruce_taiga", WOODS), + Map.entry("minecraft:windswept_hills", FELLS), + Map.entry("minecraft:windswept_forest", WOODS), + Map.entry("minecraft:windswept_gravelly_hills", FELLS), + Map.entry("minecraft:jagged_peaks", FELLS), + Map.entry("minecraft:stony_shore", FELLS), + // Temperate + Map.entry("minecraft:plains", WOODS), + Map.entry("minecraft:sunflower_plains", WOODS), + Map.entry("minecraft:forest", WOODS), + Map.entry("minecraft:birch_forest", WOODS), + Map.entry("minecraft:old_growth_birch_forest", WOODS), + Map.entry("minecraft:flower_forest", WOODS), + Map.entry("minecraft:dark_forest", WOODS), + Map.entry("minecraft:pale_garden", NONE), + Map.entry("minecraft:meadow", WOODS), + Map.entry("minecraft:cherry_grove", WOODS), + Map.entry("minecraft:swamp", List.of(FOSSILS, PORTALS)), + Map.entry("minecraft:stony_peaks", FELLS), + // Lukewarm + Map.entry("minecraft:savanna", List.of(CAMP, FOSSILS)), + Map.entry("minecraft:savanna_plateau", List.of(CAMP, FOSSILS)), + Map.entry("minecraft:windswept_savanna", List.of(CAMP, FOSSILS)), + Map.entry("minecraft:sparse_jungle", JUNGLES), + Map.entry("minecraft:jungle", JUNGLES), + Map.entry("minecraft:wooded_badlands", DRYLANDS), + // Warm + Map.entry("minecraft:desert", DRYLANDS), + Map.entry("minecraft:badlands", DRYLANDS), + Map.entry("minecraft:eroded_badlands", DRYLANDS), + Map.entry("minecraft:bamboo_jungle", JUNGLES), + Map.entry("minecraft:mangrove_swamp", List.of(FOSSILS)), + // Pristine + Map.entry(GalaxyEngine.MUSHROOM_BIOME, NONE)); + + private IsletStructures() { + // Static use only + } + + /** + * The structure standing on an islet, if any. Same seed, same islet, same + * answer - which is what lets the placing chunk be generated in any order + * on any server. + * + * @param seed the galaxy seed + * @param islet the islet + * @param chance the configured per-islet chance (0-1) + * @return the placement, or empty for an untouched islet + */ + public static Optional pick(long seed, Islet islet, double chance) { + List> categories = TABLES.getOrDefault(islet.biomeKey(), NONE); + if (chance <= 0 || categories.isEmpty()) { + return Optional.empty(); + } + if (Hashing.toUnit(Hashing.cellHash(seed, islet.centerX(), islet.centerZ(), SALT_ROLL)) >= chance) { + return Optional.empty(); + } + List variants = categories.get((int) Math.floorMod( + Hashing.cellHash(seed, islet.centerX(), islet.centerZ(), SALT_CATEGORY), categories.size())); + return Optional.of(variants.get((int) Math.floorMod( + Hashing.cellHash(seed, islet.centerX(), islet.centerZ(), SALT_VARIANT), variants.size()))); + } + + /** + * Every islet biome this table covers - a completeness handle for tests: + * a new islet biome with no entry here should fail loudly, not silently + * never decorate. + * + * @return the covered biome keys + */ + public static java.util.Set coveredBiomes() { + return TABLES.keySet(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/SurfaceKind.java b/src/main/java/world/bentobox/tradewinds/galaxy/SurfaceKind.java index 22b37ce..a74d4f4 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/SurfaceKind.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/SurfaceKind.java @@ -2,15 +2,29 @@ /** * What the top block of a land column should be, expressed without Bukkit so - * the galaxy stays pure. {@code IslandPalette} maps these to materials. + * the galaxy stays pure. {@code IslandPalette} maps these to materials. The + * kind follows the column's governing biome, so a desert stands on sand and a + * mangrove swamp on mud instead of everything wearing grass. * * @author tastybento */ public enum SurfaceKind { /** Ordinary land: grass, and whatever vanilla decoration plants on it. */ GRASS, - /** The sandy fringe of an islet - where beached shipwrecks and buried treasure belong. */ + /** Deserts and beach fringes - where shipwrecks beach and treasure buries. */ SAND, /** A mushroom island's mycelium. */ - MYCELIUM + MYCELIUM, + /** The badlands: red sand over terracotta. */ + RED_SAND, + /** Old-growth taiga floor. */ + PODZOL, + /** Bare rock: stony shores and the peaks. */ + STONE, + /** Windswept gravelly hills. */ + GRAVEL, + /** Mangrove swamp floor. */ + MUD, + /** Permanent snowpack: groves, snowy slopes, ice spike fields. */ + SNOW } diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index 5ba9a49..2236a6b 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -416,7 +416,7 @@ public List getDefaultPopulators(World world) { if (decorator == null) { decorator = new IslandDecorator(addon); } - return List.of(decorator); + return List.of(decorator, new IsletDecorator(addon)); } @Override diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java index dcc7206..6ccd5c0 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandDecorator.java @@ -120,6 +120,43 @@ private void decoratePlaza(WorldInfo worldInfo, IslandSpec spec, DockPlan plan, setIfPossible(region, lx, y + 2, lz, Material.LANTERN); } + // The galley: a public workbench and cooking hearth beside the quay + // entrance, so any sailor can craft and cook their catch the moment + // they step ashore. CRAFTING and FURNACE (which governs campfires) are + // visitor-rank at every port, and BREAK_BLOCKS is not - usable by all, + // removable by none. The campfire stands on a cobblestone hearth so + // residents do not path across open flame. + double galleyAngle = plan.bearing() + 0.7; + int galleyR = plan.plazaRadius() - 4; + int gx = plan.plazaX() + (int) Math.round(Math.cos(galleyAngle) * galleyR); + int gz = plan.plazaZ() + (int) Math.round(Math.sin(galleyAngle) * galleyR); + setIfPossible(region, gx, y, gz, Material.CRAFTING_TABLE); + int tangentX = (int) Math.round(-Math.sin(galleyAngle)); + int tangentZ = (int) Math.round(Math.cos(galleyAngle)); + setIfPossible(region, gx + tangentX * 2, y, gz + tangentZ * 2, Material.COBBLESTONE); + setIfPossible(region, gx + tangentX * 2, y + 1, gz + tangentZ * 2, Material.CAMPFIRE); + + // Higher tech furnishes the plaza further: the amenity row continues + // the ring on from the galley - smelting and stonework where there is + // industry, brewing at developed ports, and at the top of the tree an + // enchanting table with a bookshelf arc behind it + List amenities = IslandPalette.amenities(spec.techLevel()); + for (int i = 0; i < amenities.size(); i++) { + double angle = galleyAngle + 0.25 * (i + 1); + int ax = plan.plazaX() + (int) Math.round(Math.cos(angle) * galleyR); + int az = plan.plazaZ() + (int) Math.round(Math.sin(angle) * galleyR); + Material amenity = amenities.get(i); + setIfPossible(region, ax, y, az, amenity); + if (amenity == Material.ENCHANTING_TABLE) { + for (int k = -2; k <= 2; k++) { + int bx = plan.plazaX() + (int) Math.round(Math.cos(angle + k * 0.12) * (galleyR + 2)); + int bz = plan.plazaZ() + (int) Math.round(Math.sin(angle + k * 0.12) * (galleyR + 2)); + setIfPossible(region, bx, y, bz, Material.BOOKSHELF); + setIfPossible(region, bx, y + 1, bz, Material.BOOKSHELF); + } + } + } + // Market stalls on a ring: fence corners, wool canopy, a barrel counter, // and a profession workstation beside each stall int stalls = 2 + rand.nextInt(3); diff --git a/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java b/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java index cbf8915..43f41db 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IslandPalette.java @@ -102,21 +102,71 @@ public static List workstations(IslandType type) { * @param kind the surface kind * @return the surface material */ + /** + * The public plaza amenities an island's tech level provides, cumulative: + * every port has the galley (workbench + campfire, placed separately); + * real industry brings smelting and stonework; high tech brews; the top of + * the tree enchants. All governed by visitor-rank flags (FURNACE covers + * the brewing stand per BentoBox's interaction listener; ENCHANTING, + * ANVIL, GRINDSTONE, SMITHING, STONECUTTING are their own flags, none + * denied at ports). + * + * @param techLevel the island's tech level (1-7) + * @return amenity blocks in placement order + */ + public static List amenities(int techLevel) { + List list = new java.util.ArrayList<>(); + if (techLevel >= 3) { + list.add(Material.FURNACE); + list.add(Material.STONECUTTER); + } + if (techLevel >= 4) { + list.add(Material.SMITHING_TABLE); + list.add(Material.GRINDSTONE); + } + if (techLevel >= 5) { + list.add(Material.BREWING_STAND); + list.add(Material.CAULDRON); + } + if (techLevel >= 6) { + list.add(Material.ANVIL); + } + if (techLevel >= 7) { + list.add(Material.ENCHANTING_TABLE); + } + return list; + } + public static Material surface(SurfaceKind kind) { return switch (kind) { case SAND -> Material.SAND; case MYCELIUM -> Material.MYCELIUM; + case RED_SAND -> Material.RED_SAND; + case PODZOL -> Material.PODZOL; + case STONE -> Material.STONE; + case GRAVEL -> Material.GRAVEL; + case MUD -> Material.MUD; + case SNOW -> Material.SNOW_BLOCK; case GRASS -> Material.GRASS_BLOCK; }; } /** - * What sits under that surface, before the column turns to stone. + * What sits under that surface, before the column turns to stone. Sand + * needs sandstone under it (and red sand its terracotta) or the first + * shovelful finds lawn under the dunes. * * @param kind the surface kind * @return the subsoil material */ public static Material subsoil(SurfaceKind kind) { - return kind == SurfaceKind.SAND ? Material.SANDSTONE : Material.DIRT; + return switch (kind) { + case SAND -> Material.SANDSTONE; + case RED_SAND -> Material.TERRACOTTA; + case STONE -> Material.STONE; + case GRAVEL -> Material.GRAVEL; + case MUD -> Material.MUD; + case GRASS, MYCELIUM, PODZOL, SNOW -> Material.DIRT; + }; } } diff --git a/src/main/java/world/bentobox/tradewinds/generator/IsletDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IsletDecorator.java new file mode 100644 index 0000000..daaa12c --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/generator/IsletDecorator.java @@ -0,0 +1,134 @@ +package world.bentobox.tradewinds.generator; + +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.block.structure.Mirror; +import org.bukkit.block.structure.StructureRotation; +import org.bukkit.generator.BlockPopulator; +import org.bukkit.generator.LimitedRegion; +import org.bukkit.generator.WorldInfo; +import org.bukkit.structure.Structure; +import org.bukkit.util.BlockVector; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.Islet; +import world.bentobox.tradewinds.galaxy.IsletStructures; + +/** + * Places the small vanilla structure some wild islets carry - an igloo on the + * snow, fossils in the sand, a ruined portal in the jungle, an abandoned camp + * in the woods. {@link IsletStructures} decides WHAT (pure, seeded); this + * populator only loads the chosen template and stamps it down when the chunk + * holding the islet's center generates. The whole template goes down in one + * populate call, so there is nothing to keep consistent across chunks. + * + * @author tastybento + */ +public class IsletDecorator extends BlockPopulator { + + private final TradeWinds addon; + /** Template cache - the same few NBTs place on thousands of islets. */ + private final Map templates = new HashMap<>(); + + public IsletDecorator(TradeWinds addon) { + this.addon = addon; + } + + @Override + public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int chunkX, int chunkZ, + @NonNull LimitedRegion limitedRegion) { + if (worldInfo.getEnvironment() != World.Environment.NORMAL) { + return; + } + double chance = addon.getSettings().getIsletStructureChance(); + if (chance <= 0) { + return; + } + GalaxyEngine engine = addon.getGalaxyEngine(worldInfo.getSeed()); + int grid = engine.getConfig().wildIsletGrid(); + if (grid <= 0) { + return; + } + int minX = chunkX << 4; + int minZ = chunkZ << 4; + // Islets whose center falls in this chunk - the anchor that makes the + // placement happen exactly once however chunks are visited + for (int cellX = Math.floorDiv(minX, grid); cellX <= Math.floorDiv(minX + 15, grid); cellX++) { + for (int cellZ = Math.floorDiv(minZ, grid); cellZ <= Math.floorDiv(minZ + 15, grid); cellZ++) { + Islet islet = engine.wildIsletInCell(cellX, cellZ).orElse(null); + if (islet == null || islet.centerX() < minX || islet.centerX() >= minX + 16 + || islet.centerZ() < minZ || islet.centerZ() >= minZ + 16) { + continue; + } + IsletStructures.pick(engine.getConfig().seed(), islet, chance).ifPresent( + placement -> place(engine, islet, placement, limitedRegion, worldInfo, random)); + } + } + } + + private void place(GalaxyEngine engine, Islet islet, IsletStructures.Placement placement, + LimitedRegion region, WorldInfo worldInfo, Random random) { + Structure structure = templates.computeIfAbsent(placement.template(), + key -> Bukkit.getStructureManager().loadStructure(NamespacedKey.minecraft(key))); + if (structure == null) { + return; + } + BlockVector size = structure.getSize(); + // Centered on the islet's heart, base sunk per the placement - the + // surface height is pure galaxy arithmetic, no block reads + int baseX = islet.centerX() - size.getBlockX() / 2; + int baseZ = islet.centerZ() - size.getBlockZ() / 2; + int baseY = engine.surfaceHeightAt(islet.centerX(), islet.centerZ()) + 1 - placement.sink(); + if (baseY < worldInfo.getMinHeight() + 1 + || baseY + size.getBlockY() >= worldInfo.getMaxHeight()) { + return; + } + // The populate region has a limited buffer around the chunk: a + // template that would poke out is skipped rather than truncated + if (!region.isInRegion(baseX, baseY, baseZ) + || !region.isInRegion(baseX + size.getBlockX() - 1, baseY, baseZ + size.getBlockZ() - 1)) { + return; + } + structure.place(region, new BlockVector(baseX, baseY, baseZ), false, StructureRotation.NONE, + Mirror.NONE, -1, 1.0f, random); + scrubScaffolding(region, placement, baseX, baseY, baseZ, size); + } + + /** + * Take out the pieces vanilla's assembler would have consumed: JIGSAW + * connectors become the template's own {@code final_state}, and STRUCTURE + * blocks (markers for entities and further pieces) simply go. Pasting a + * template raw leaves both standing - a row of glowing jigsaws holding up + * a tent, as found on an islet in the 2026-08-02 playtest. + */ + private void scrubScaffolding(LimitedRegion region, IsletStructures.Placement placement, int baseX, + int baseY, int baseZ, BlockVector size) { + Material fill = Material.matchMaterial(placement.jigsawFill()); + if (fill == null) { + fill = Material.AIR; + } + for (int x = baseX; x < baseX + size.getBlockX(); x++) { + for (int y = baseY; y < baseY + size.getBlockY(); y++) { + for (int z = baseZ; z < baseZ + size.getBlockZ(); z++) { + if (!region.isInRegion(x, y, z)) { + continue; + } + Material here = region.getType(x, y, z); + if (here == Material.JIGSAW) { + region.setType(x, y, z, fill); + } else if (here == Material.STRUCTURE_BLOCK || here == Material.STRUCTURE_VOID) { + region.setType(x, y, z, Material.AIR); + } + } + } + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java index 5a0a9bb..3219729 100644 --- a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java +++ b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java @@ -1,12 +1,14 @@ package world.bentobox.tradewinds.listeners; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.World; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerRespawnEvent; +import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; /** @@ -14,6 +16,12 @@ * respawn anchor respawns on the spawn islet - never in the seabed. (Vanilla * hunts for solid ground near world spawn; in an ocean world that used to * mean being entombed at the bottom and dying on repeat.) + *

+ * Death also drops the boat where they fell, so a respawned sailor would be + * marooned ashore with no way back to their own wreck. The port lends them a + * boat ({@code boats.respawn-boat}, default a bamboo raft; NONE disables) - + * but only if they have none, so keepInventory deaths and bed respawns are + * never double-boated. * * @author tastybento */ @@ -31,6 +39,7 @@ public void onRespawn(PlayerRespawnEvent event) { if (!world.equals(addon.getOverWorld()) && !world.equals(addon.getNetherWorld())) { return; } + grantRespawnBoat(event); if (event.isBedSpawn() || event.isAnchorSpawn()) { return; // they made a home somewhere - honor it } @@ -44,4 +53,56 @@ public void onRespawn(PlayerRespawnEvent event) { Location point = addon.getIslands().getSpawnPoint(overworld); event.setRespawnLocation(point != null ? point : overworld.getSpawnLocation()); } + + /** + * The port's loaner: a boatless respawner gets the configured hull (their + * own boat is floating back where they died, and rowing out to reclaim it + * is the intended recovery trip). + */ + private void grantRespawnBoat(PlayerRespawnEvent event) { + Material boat = respawnBoat(); + if (boat == null || boatWithinReach(event)) { + return; + } + // They have no boat, or theirs is far away: lend a hull. If they had + // one, boarding this raft is what abandons it (with the standard + // confirmation) - the loaner itself takes nothing from them. + var hold = addon.getHoldService().active(event.getPlayer().getUniqueId()).isEmpty() + ? addon.getBoatService().createFor(event.getPlayer(), boat) + : addon.getHoldManager().create(boat, null); + addon.getBoatService().giveBoatItem(event.getPlayer(), hold); + User.getInstance(event.getPlayer()).sendMessage("tradewinds.boat.respawn-given", "[material]", + world.bentobox.tradewinds.economy.PriceEngine.prettify(boat.name())); + } + + /** + * Whether their own boat is close enough to the respawn point to walk to + * - otherwise a sailor is stranded ashore with a ship an ocean away. + */ + private boolean boatWithinReach(PlayerRespawnEvent event) { + var hold = addon.getHoldService().active(event.getPlayer().getUniqueId()); + if (hold.isEmpty() || hold.get().getWorld() == null || hold.get().getWorld().isEmpty()) { + return false; + } + Location respawn = event.getRespawnLocation(); + if (respawn.getWorld() == null || !respawn.getWorld().getName().equals(hold.get().getWorld())) { + return false; + } + int reach = addon.getSettings().getIslandProtectionRange(); + return respawn.distanceSquared(new Location(respawn.getWorld(), hold.get().getX(), hold.get().getY(), + hold.get().getZ())) <= (double) reach * reach; + } + + /** + * The configured respawn boat, or null when disabled (NONE/blank/unknown + * or off-ladder names all disable - an admin typo must not hand out air). + */ + private Material respawnBoat() { + String name = addon.getSettings().getRespawnBoat(); + if (name == null || name.isBlank() || "NONE".equalsIgnoreCase(name)) { + return null; + } + Material material = Material.matchMaterial(name); + return material != null && addon.getBoatRanks().slots(material) > 0 ? material : null; + } } diff --git a/src/main/java/world/bentobox/tradewinds/tasks/BorderCurtainTask.java b/src/main/java/world/bentobox/tradewinds/tasks/BorderCurtainTask.java new file mode 100644 index 0000000..8354b89 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/tasks/BorderCurtainTask.java @@ -0,0 +1,142 @@ +package world.bentobox.tradewinds.tasks; + +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.Particle; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; + +import com.google.common.base.Enums; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * Makes the invisible lines visible: every trading island has two boundaries + * a sailor needs to SEE - the warp-offer ring at the protection edge (where + * the dialog fires, red by default) and the edge of island space itself + * (where scans and island rules begin, blue by default). Each renders as a + * curtain of dust on the arc nearest the player, per player, only when they + * are close enough for it to matter. + *

+ * Rendering follows the Border addon's proven path exactly + * ({@code User.spawnParticle}, which resolves DUST across versions, validates + * the dust options and applies the server's view-distance check) and hangs + * the curtain around the player's own Y rather than an assumed sea + * level - a boat sits above the waterline, and the first cut drew the wall at + * a height that could be behind the horizon of what the client renders. + *

+ * The border stays fully passable: this is paint, not a wall. + * + * @author tastybento + */ +public class BorderCurtainTask { + + /** Ticks between redraws - dust lives ~1s, so this keeps it steady. */ + private static final long PERIOD_TICKS = 10L; + /** Block spacing between particles along the arc. */ + private static final double ARC_STEP = 1.5; + /** Curtain heights relative to the player's feet. */ + private static final double[] CURTAIN_YS = { -1.0, 0.5, 2.0, 3.5 }; + + /** DUST on modern servers, REDSTONE on older, FLAME as a last resort. */ + private static final Particle PARTICLE = Enums.getIfPresent(Particle.class, "DUST") + .or(Enums.getIfPresent(Particle.class, "REDSTONE").or(Particle.FLAME)); + + private final TradeWinds addon; + private BukkitTask task; + + public BorderCurtainTask(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), this::tick, PERIOD_TICKS, PERIOD_TICKS); + addon.log("Border curtains: " + (addon.getSettings().isBorderParticlesEnabled() ? "on" : "OFF (border.particles-enabled)") + + ", warp ring " + addon.getSettings().getIslandProtectionRange() + " blocks, island edge " + + addon.getSettings().getIslandDistance() + " blocks"); + } + + public void stop() { + if (task != null) { + task.cancel(); + } + } + + private void tick() { + if (!addon.getSettings().isBorderParticlesEnabled()) { + return; + } + World world = addon.getOverWorld(); + if (world == null) { + return; + } + Color warpColor = parseColor(addon.getSettings().getWarpRingColor(), Color.fromRGB(255, 64, 64)); + Color edgeColor = parseColor(addon.getSettings().getEdgeRingColor(), Color.fromRGB(64, 128, 255)); + int view = addon.getSettings().getBorderViewDistance(); + int warpRing = addon.getSettings().getIslandProtectionRange(); + int edgeRing = addon.getSettings().getIslandDistance(); + for (Player player : world.getPlayers()) { + User user = User.getInstance(player); + for (IslandSpec spec : addon.getGalaxyEngine(world.getSeed()).islandsNear( + player.getLocation().getBlockX(), player.getLocation().getBlockZ(), edgeRing + view)) { + drawRing(user, player, spec, warpRing, view, warpColor); + drawRing(user, player, spec, edgeRing, view, edgeColor); + } + } + } + + /** + * Draw the arc of one ring nearest the player, if they are within view of + * it: a dust curtain a few blocks tall, hung around their own height. + */ + private void drawRing(User user, Player player, IslandSpec spec, int radius, int view, Color color) { + double px = player.getLocation().getX(); + double pz = player.getLocation().getZ(); + double py = player.getLocation().getY(); + double distance = Math.hypot(px - spec.centerX(), pz - spec.centerZ()); + if (Math.abs(distance - radius) > view || radius <= 0) { + return; + } + Particle.DustOptions dust = new Particle.DustOptions(color, 2.0f); + double bearing = Math.atan2(pz - spec.centerZ(), px - spec.centerX()); + // The arc spanning ~view blocks either side of the player's bearing + double halfArc = (double) view / radius; + double step = ARC_STEP / radius; + for (double theta = bearing - halfArc; theta <= bearing + halfArc; theta += step) { + double x = spec.centerX() + radius * Math.cos(theta); + double z = spec.centerZ() + radius * Math.sin(theta); + for (double dy : CURTAIN_YS) { + user.spawnParticle(PARTICLE, dust, x, py + dy, z); + } + } + } + + /** + * Parse a config color: "r,g,b" (0-255 each), falling back rather than + * failing - a typo must not strip the border off the sea. + * + * @param raw the config string + * @param fallback the default + * @return the color + */ + static Color parseColor(String raw, Color fallback) { + if (raw == null || raw.isBlank()) { + return fallback; + } + String[] parts = raw.split(","); + if (parts.length != 3) { + return fallback; + } + try { + int r = Math.clamp(Integer.parseInt(parts[0].trim()), 0, 255); + int g = Math.clamp(Integer.parseInt(parts[1].trim()), 0, 255); + int b = Math.clamp(Integer.parseInt(parts[2].trim()), 0, 255); + return Color.fromRGB(r, g, b); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/tasks/FuelWarningTask.java b/src/main/java/world/bentobox/tradewinds/tasks/FuelWarningTask.java index 26a516f..14ce04d 100644 --- a/src/main/java/world/bentobox/tradewinds/tasks/FuelWarningTask.java +++ b/src/main/java/world/bentobox/tradewinds/tasks/FuelWarningTask.java @@ -66,6 +66,12 @@ public void run() { } private void check(Player player, IslandSpec port) { + // No ship, no fuel tank: nagging a boatless sailor about fuel is + // noise they cannot act on (playtest 2026-08-02) + if (addon.getHoldService().active(player.getUniqueId()).isEmpty()) { + warnedAt.remove(player.getUniqueId()); + return; + } double fuel = addon.getFuelService().holdFuel(player); int cheapest = FuelWarning.cheapestRoute(addon.getWarpService().destinations(player, port, fuel)); if (!FuelWarning.isLow(fuel, cheapest, addon.getSettings().getFuelWarningMargin())) { diff --git a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java index f4d972a..5f4da75 100644 --- a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java +++ b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java @@ -112,6 +112,7 @@ private void show(Player player, Reading reading) { User user = User.getInstance(player); Component name = user.getTranslationAsComponent("tradewinds.hud.navigation", "[name]", reading.island().name(), + "[tech]", String.valueOf(reading.island().techLevel()), "[band]", user.getTranslation(reading.island().band().getLocaleKey()), "[standing]", addon.getPlayerStanding(user, player.getUniqueId()), "[distance]", String.valueOf(reading.dockDistance())); diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java new file mode 100644 index 0000000..55952fb --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatCraftListener.java @@ -0,0 +1,116 @@ +package world.bentobox.tradewinds.travel; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.CraftItemEvent; +import org.bukkit.inventory.ItemStack; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; + +/** + * Crafting a boat is the exploration route up the ladder (rare wood instead + * of money) - but it obeys the One Boat Rule like everything else: a crafted + * boat REPLACES yours, so crafting one smaller than or equal to your current + * boat is refused (you would be burning wood to destroy cargo capacity). + * Adding a chest to your boat (the vanilla boat+chest recipe) is the in-place + * upgrade this deliberately allows. + *

+ * Tech levels gate shops only - crafting works anywhere, including at sea. + * + * @author tastybento + */ +public class BoatCraftListener implements Listener { + + private final TradeWinds addon; + + public BoatCraftListener(TradeWinds addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onCraft(CraftItemEvent event) { + ItemStack result = event.getRecipe().getResult(); + if (!(event.getWhoClicked() instanceof Player player) || result == null + || !BoatRanks.isBoatItem(result.getType())) { + return; + } + if (!player.getWorld().equals(addon.getOverWorld()) + && !player.getWorld().equals(addon.getNetherWorld())) { + return; + } + BoatRanks ranks = addon.getBoatRanks(); + Material current = addon.getHoldService().boat(player); + int newSlots = ranks.slots(result.getType()); + if (newSlots <= 0) { + // Not on the ladder: an ordinary vanilla boat, none of our business + return; + } + if (current != null && newSlots <= ranks.slots(current)) { + event.setCancelled(true); + User.getInstance(player).sendMessage("tradewinds.trade.craft-smaller-refused"); + return; + } + // An upgrade: the crafted item becomes the boat, the old avatar goes. + // Done next tick, once the crafted item actually exists in the pack. + Material old = current; + org.bukkit.Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { + // A refit: the same hold, a bigger hull around it + var record = addon.getHoldService().active(player.getUniqueId()); + var hold = record.orElseGet(() -> addon.getBoatService().createFor(player, result.getType())); + hold.setMaterial(result.getType().name()); + addon.getHoldManager().save(hold); + addon.getBoatService().relabel(hold); + // Stamp the hull they just built, or it is an anonymous boat + // item that merely matches their record by type - no lore, and + // right-clicking it opens nothing + if (!stampCrafted(player, result.getType(), hold)) { + // Clicking the result puts the boat on the CURSOR and it may + // not reach a slot for a tick or two - try once more before + // giving up (playtest 2026-08-02: a crafted acacia boat came + // out blank because only the inventory was searched) + org.bukkit.Bukkit.getScheduler().runTaskLater(addon.getPlugin(), + () -> stampCrafted(player, result.getType(), hold), 5L); + } + if (old != null) { + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getType() == old) { + stack.setAmount(stack.getAmount() - 1); + break; + } + } + } + User.getInstance(player).sendMessage("tradewinds.trade.boat-crafted", "[material]", + world.bentobox.tradewinds.economy.PriceEngine.prettify(result.getType().name()), + "[slots]", String.valueOf(newSlots)); + }); + } + + /** + * Give the freshly crafted hull its record. The item may be on the + * CURSOR (a normal click on the result slot) rather than in a slot (a + * shift-click), and only the second was searched before. + * + * @param player the crafter + * @param type the boat they made + * @param hold their boat record + * @return true if a hull was stamped + */ + private boolean stampCrafted(Player player, Material type, world.bentobox.tradewinds.dataobjects.BoatHold hold) { + ItemStack cursor = player.getItemOnCursor(); + if (cursor != null && cursor.getType() == type && BoatService.boatId(cursor) == null) { + player.setItemOnCursor(addon.getBoatService().stamp(cursor, hold)); + return true; + } + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getType() == type && BoatService.boatId(stack) == null) { + addon.getBoatService().stamp(stack, hold); + return true; + } + } + return false; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java new file mode 100644 index 0000000..510abb3 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java @@ -0,0 +1,616 @@ +package world.bentobox.tradewinds.travel; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Boat; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.ItemDespawnEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.vehicle.VehicleDestroyEvent; +import org.bukkit.event.vehicle.VehicleEnterEvent; +import org.bukkit.event.vehicle.VehicleExitEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.BoatHold; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * A boat's life in the world (rules ruled 2026-08-02): + *

    + *
  • Capture by boarding. Boarding a boat that is not yours makes it + * yours - after a confirmation, always, whatever its size, because the boat + * you leave behind becomes an unowned OLD BOAT with your cargo still in it. + * Riding as a passenger never takes over.
  • + *
  • Capture by pickup. Same confirmation for a boat ITEM: pickups + * are automatic, so the first attempt is cancelled and prompts instead + * (re-prompts suppressed briefly so walking about does not spam).
  • + *
  • Protection. An OWNED boat left unattended inside an island's + * protected space cannot be boarded or broken by anyone else - not even by + * mobs - except on ANARCHIC islands. Unowned boats are never protected, + * anywhere: that is what makes an abandoned hull fair game.
  • + *
  • Death leaves the boat floating where it is; it stays yours.
  • + *
  • Logging out outside protection risks the boat being taken; the + * loss is reported at the next login.
  • + *
+ * + * @author tastybento + */ +public class BoatListener implements Listener { + + /** How long a refused item pickup stays quiet before prompting again. */ + private static final long PROMPT_SUPPRESSION_MS = 15_000; + /** + * How long after a swap boat items are ignored entirely. Taking a boat + * leaves the old hull at your feet, and walking over it asked at once + * whether you would like to swap back (playtest 2026-08-02). The + * per-hull suppression cannot help: the hull you just shed is a + * different boat, seen for the first time. + */ + private static final long SWAP_QUIET_MS = 5_000; + /** How often ridden boats write their position back (chart accuracy). */ + private static final long TRACK_PERIOD_TICKS = 40L; + + private final TradeWinds addon; + /** player -> boatId -> when we last prompted about it. */ + private final Map> prompted = new HashMap<>(); + /** player -> when boat items become interesting again after a swap. */ + private final Map swapQuietUntil = new HashMap<>(); + private BukkitTask tracker; + + public BoatListener(TradeWinds addon) { + this.addon = addon; + } + + public void start() { + tracker = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), () -> { + trackRiddenBoats(); + if (++sweepCounter % SWEEPS_PER_MINUTE == 0) { + sweepExpiredItems(); + } + }, TRACK_PERIOD_TICKS, TRACK_PERIOD_TICKS); + } + + private int sweepCounter; + /** 40-tick tracker ticks per TTL sweep (about a minute). */ + private static final int SWEEPS_PER_MINUTE = 30; + + public void stop() { + if (tracker != null) { + tracker.cancel(); + } + } + + /** + * Keep the chart honest about where a boat under way actually is. + */ + private void trackRiddenBoats() { + for (World world : ourWorlds()) { + for (Player player : world.getPlayers()) { + if (player.getVehicle() instanceof Boat boat) { + String id = BoatService.boatId(boat); + addon.getHoldManager().boat(id) + .ifPresent(hold -> addon.getHoldManager().rememberPosition(hold, boat.getLocation())); + } + } + } + } + + private java.util.List ourWorlds() { + java.util.List worlds = new java.util.ArrayList<>(); + if (addon.getOverWorld() != null) { + worlds.add(addon.getOverWorld()); + } + if (addon.getNetherWorld() != null) { + worlds.add(addon.getNetherWorld()); + } + return worlds; + } + + /** + * Placing a boat item spawns a plain vanilla entity that knows nothing + * about the hold it came from - so the sailor who launched their own boat + * was met with "take the Oak Boat?" over their own ship. Carry the + * record's id from the item onto the entity as it is placed, and the hull + * in the water IS the boat they were holding. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlace(org.bukkit.event.entity.EntityPlaceEvent event) { + if (!(event.getEntity() instanceof Boat boat) || event.getPlayer() == null + || !inOurWorlds(boat.getWorld())) { + return; + } + ItemStack used = event.getHand() == org.bukkit.inventory.EquipmentSlot.OFF_HAND + ? event.getPlayer().getInventory().getItemInOffHand() + : event.getPlayer().getInventory().getItemInMainHand(); + Optional hold = addon.getBoatService().recordFor(used); + if (hold.isEmpty()) { + return; // A boat with no record: recordFor(entity) registers it later + } + BoatHold record = hold.get(); + record.setExpiresAt(0); // Afloat again: only ITEM hulls run a clock + addon.getBoatService().stamp(boat, record); + addon.getHoldManager().rememberPosition(record, boat.getLocation()); + } + + // -------------------------------------------------------------- boarding + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onEnter(VehicleEnterEvent event) { + if (!(event.getVehicle() instanceof Boat boat) || !(event.getEntered() instanceof Player player) + || !inOurWorlds(boat.getWorld())) { + return; + } + // A passenger climbing aboard an occupied boat takes nothing over + if (!boat.getPassengers().isEmpty()) { + return; + } + BoatHold hold = addon.getBoatService().recordFor(boat); + UUID playerId = player.getUniqueId(); + if (playerId.toString().equals(hold.getOwner())) { + // Their own boat: make sure it is the active one and board + addon.getBoatService().claim(player, hold); + hideLabel(boat); + return; + } + if (isProtected(hold, boat.getLocation())) { + event.setCancelled(true); + User.getInstance(player).sendMessage("tradewinds.boat.protected"); + return; + } + // Capture: always ask - the boat they leave behind keeps their cargo, + // and if their own boat is alongside they may prefer just the cargo + event.setCancelled(true); + offerFoundBoat(player, hold, () -> { + takeBoat(player, hold); + boat.addPassenger(player); + hideLabel(boat); + }); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onExit(VehicleExitEvent event) { + if (!(event.getVehicle() instanceof Boat boat) || !inOurWorlds(boat.getWorld())) { + return; + } + addon.getHoldManager().boat(BoatService.boatId(boat)).ifPresent(hold -> { + addon.getHoldManager().rememberPosition(hold, boat.getLocation()); + // Show the plate again a tick later, once the seat is empty + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> addon.getBoatService().label(boat, hold)); + }); + } + + private void hideLabel(Boat boat) { + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> boat.setCustomNameVisible(false)); + } + + // ------------------------------------------------------------ protection + + /** + * An owned boat sitting unattended in an island's PROTECTED space is + * untouchable by anyone but its owner - unless the island is ANARCHIC. + * Unowned boats are never protected. + */ + boolean isProtected(BoatHold hold, Location where) { + if (hold.isUnowned() || where == null || where.getWorld() == null + || !where.getWorld().equals(addon.getOverWorld())) { + return false; + } + Optional island = islandProtecting(where); + return island.isPresent() && island.get().band() != world.bentobox.tradewinds.galaxy.SecurityBand.ANARCHIC; + } + + /** + * The island whose PROTECTED space (not merely its island space) covers a + * position - the trading gate and boat protection share this reach. + */ + public Optional islandProtecting(Location where) { + if (where == null || where.getWorld() == null || !where.getWorld().equals(addon.getOverWorld())) { + return Optional.empty(); + } + int range = addon.getSettings().getIslandProtectionRange(); + return addon.getGalaxyEngine(where.getWorld().getSeed()) + .islandsNear(where.getBlockX(), where.getBlockZ(), range).stream() + .filter(spec -> spec.distanceSquared(where.getBlockX(), where.getBlockZ()) <= (long) range * range) + .findFirst(); + } + + /** + * Breaking a boat: the owner always may (that is the logout-safety + * route); anyone else is refused while it is protected. A broken boat + * always yields its item, cargo record and all - lava is the only thing + * that truly destroys one. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onDestroy(VehicleDestroyEvent event) { + if (!(event.getVehicle() instanceof Boat boat) || !inOurWorlds(boat.getWorld())) { + return; + } + BoatHold hold = addon.getBoatService().recordFor(boat); + boolean byOwner = event.getAttacker() instanceof Player breaker + && breaker.getUniqueId().toString().equals(hold.getOwner()); + if (!byOwner && isProtected(hold, boat.getLocation())) { + event.setCancelled(true); + if (event.getAttacker() instanceof Player breaker) { + User.getInstance(breaker).sendMessage("tradewinds.boat.protected"); + } + return; + } + Location where = boat.getLocation(); + if (boat.isInLava()) { + // The one true destroyer: the hull and everything in it burn + forgetBoat(hold); + return; + } + event.setCancelled(true); + boat.eject(); + boat.remove(); + Material material = Material.matchMaterial(hold.getMaterial()); + ItemStack item = addon.getBoatService() + .stamp(new ItemStack(material == null ? Material.OAK_BOAT : material), hold); + // Only an ITEM ever expires - boat entities stay until someone takes them + hold.setExpiresAt(System.currentTimeMillis() + addon.getSettings().getDroppedBoatTtlMinutes() * 60_000L); + addon.getHoldManager().rememberPosition(hold, where); + where.getWorld().dropItemNaturally(where, item); + } + + /** + * A boat that burned: the record goes, and anyone pointing at it forgets. + */ + private void forgetBoat(BoatHold hold) { + if (!hold.isUnowned()) { + UUID owner = UUID.fromString(hold.getOwner()); + addon.getHoldManager().clearActiveBoat(owner); + } + addon.getHoldManager().ownerOfOldBoat(hold.getUniqueId()) + .ifPresent(addon.getHoldManager()::clearOldBoat); + addon.getHoldManager().delete(hold.getUniqueId()); + } + + // ---------------------------------------------------------------- items + + /** + * Dropping your own boat item just puts it on the ground - the cargo was + * always the boat's, so nothing has to move. It keeps its owner (and so + * its protection) until someone claims it. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDrop(PlayerDropItemEvent event) { + ItemStack stack = event.getItemDrop().getItemStack(); + addon.getBoatService().recordFor(stack).ifPresent(hold -> { + hold.setExpiresAt( + System.currentTimeMillis() + addon.getSettings().getDroppedBoatTtlMinutes() * 60_000L); + addon.getHoldManager().rememberPosition(hold, event.getItemDrop().getLocation()); + }); + } + + /** + * Picking up a boat item. Yours: just a pickup. Smaller or equal: you + * take the goods and leave the hull (you never pocket a spare boat). + * Bigger, or you have none: it becomes your boat - after the same + * confirmation boarding gets, because your old boat keeps its cargo. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPickup(EntityPickupItemEvent event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + ItemStack stack = event.getItem().getItemStack(); + if (!inOurWorlds(player.getWorld()) || !BoatRanks.isBoatItem(stack.getType()) + || addon.getBoatRanks().slots(stack.getType()) <= 0) { + return; + } + UUID playerId = player.getUniqueId(); + BoatHold hold = addon.getBoatService().recordFor(stack).orElseGet(() -> { + // A plain vanilla boat item: register it as the unowned hull it is + BoatHold fresh = addon.getHoldManager().create(stack.getType(), null); + addon.getBoatService().stamp(stack, fresh); + event.getItem().setItemStack(stack); + return fresh; + }); + if (playerId.toString().equals(hold.getOwner())) { + hold.setExpiresAt(0); + addon.getHoldManager().save(hold); + addon.getHoldManager().setActiveBoat(playerId, hold); + return; // their own boat, back in the pack + } + if (System.currentTimeMillis() < swapQuietUntil.getOrDefault(playerId, 0L)) { + // Just swapped: leave the hull we shed alone for a moment rather + // than offer to swap straight back + event.setCancelled(true); + return; + } + if (addon.getHoldService().active(playerId).isEmpty()) { + // Nothing to lose: claim it outright, no dialog + event.getItem().remove(); + hold.setExpiresAt(0); + addon.getBoatService().claim(player, hold); + addon.getBoatService().giveBoatItem(player, hold); + User.getInstance(player).sendMessage("tradewinds.boat.claimed", "[material]", + pretty(Material.matchMaterial(hold.getMaterial()))); + return; + } + // Someone else's hull, and they have a boat of their own: ask. Pickup + // is automatic, so refuse it and prompt (rate-limited - standing near + // an item fires this every tick) + event.setCancelled(true); + if (!shouldPrompt(playerId, hold.getUniqueId())) { + return; + } + offerFoundBoat(player, hold, () -> { + event.getItem().remove(); + hold.setExpiresAt(0); + takeBoat(player, hold); + addon.getBoatService().giveBoatItem(player, hold); + }); + } + + /** + * Take a hull as your own, dealing with the boat you already had. + *

+ * If your old boat is with you - carried in your pack, or moored + * within loading range - you cannot keep both (One Boat), so its cargo + * comes across into the new hull and the empty hull is left at your feet, + * unowned. Carrying two boat items was the 2026-08-02 exploit: both + * opened the same hold, and the spare could be dropped and stripped over + * and over for free fuel. + *

+ * If the old boat is far away it simply stays where it is with its cargo + * aboard - that is the OLD BOAT you row back to. + * + * @param player the player + * @param hold the hull they are taking + */ + void takeBoat(Player player, BoatHold hold) { + swapQuietUntil.put(player.getUniqueId(), System.currentTimeMillis() + SWAP_QUIET_MS); + Optional previous = addon.getHoldService().active(player.getUniqueId()) + .filter(old -> !old.getUniqueId().equals(hold.getUniqueId())); + boolean carried = previous.isPresent() && carriedItemFor(player, previous.get()) != null; + boolean alongside = carried || ownBoatWithinReach(player); + addon.getBoatService().claim(player, hold); + if (previous.isEmpty() || !alongside) { + return; + } + // One hull, all the cargo: pour the old boat into the new one + BoatHold old = previous.get(); + salvage(player, old); + ItemStack spare = carriedItemFor(player, old); + if (spare != null) { + // The hull cannot ride in your pack alongside your ship - leave it + spare.setAmount(spare.getAmount() - 1); + ItemStack hull = addon.getBoatService() + .stamp(new ItemStack(Material.matchMaterial(old.getMaterial())), old); + player.getWorld().dropItemNaturally(player.getLocation(), hull); + addon.getHoldManager().rememberPosition(old, player.getLocation()); + } + // Shedding a hull on purpose, emptied, at your own feet is not + // "losing" a boat - do not chart it as one to go back for + if (old.isEmpty()) { + addon.getHoldManager().clearOldBoat(player.getUniqueId()); + } + User.getInstance(player).sendMessage(old.isEmpty() ? "tradewinds.boat.merged-empty" + : "tradewinds.boat.merged-partial", "[material]", pretty(Material.matchMaterial(old.getMaterial()))); + } + + /** + * The item in a player's pack that is the avatar of this boat, or null. + */ + private ItemStack carriedItemFor(Player player, BoatHold hold) { + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && hold.getUniqueId().equals(BoatService.boatId(stack))) { + return stack; + } + } + return null; + } + + /** + * The three ways to answer a hull you have found (ruled 2026-08-02): take + * it (abandoning your own where it lies), move its cargo into your own + * boat - offered ONLY while your boat is close enough to load, since + * cargo is never teleported across the ocean - or leave it be. + * + * @param player the finder + * @param hold the hull found + * @param onTake what taking it does (board it, or pocket the item) + */ + private void offerFoundBoat(Player player, BoatHold hold, Runnable onTake) { + Material taking = Material.matchMaterial(hold.getMaterial()); + Material leaving = addon.getHoldService().boat(player.getUniqueId()); + if (leaving == null) { + onTake.run(); + return; + } + int carried = hold.getContents().values().stream().mapToInt(Integer::intValue).sum() + + hold.getFuel().values().stream().mapToInt(Integer::intValue).sum(); + String cargo = carried > 0 ? String.valueOf(carried) : null; + Runnable onTransfer = carried > 0 && ownBoatWithinReach(player) ? () -> { + int moved = salvage(player, hold); + User.getInstance(player).sendMessage(moved > 0 ? "tradewinds.boat.salvage-emptied" + : "tradewinds.hold.cargo-full", "[amount]", String.valueOf(moved)); + } : null; + addon.getTradeDialog().confirmBoatFound(player, pretty(taking), pretty(leaving), cargo, onTake, + onTransfer); + } + + /** + * Whether the player's own boat is close enough to load cargo into. A + * hull an ocean away cannot receive anything: the playtest moved eight + * items into a boat thousands of blocks off, which is teleporting cargo. + * + * @param player the player + * @return true if their boat is within the configured transfer range + */ + boolean ownBoatWithinReach(Player player) { + return addon.getHoldService().active(player.getUniqueId()).map(mine -> { + if (mine.getWorld() == null || mine.getWorld().isEmpty() + || !mine.getWorld().equals(player.getWorld().getName())) { + return false; + } + long range = addon.getSettings().getCargoTransferRange(); + double dx = mine.getX() - player.getLocation().getX(); + double dz = mine.getZ() - player.getLocation().getZ(); + return dx * dx + dz * dz <= (double) range * range; + }).orElse(false); + } + + /** + * Pour a wreck's cargo into the player's hold, most valuable first (the + * base-price table is the value oracle), fuel into the fuel row. + * + * @return how many items moved + */ + int salvage(Player player, BoatHold wreck) { + int moved = 0; + java.util.List> entries = new java.util.ArrayList<>( + wreck.getContents().entrySet()); + entries.sort(java.util.Comparator.comparingDouble((Map.Entry e) -> { + Material m = Material.matchMaterial(e.getKey()); + return m == null ? 0 : addon.getMarketService().basePrice(m).orElse(0.0); + }).reversed()); + for (Map.Entry entry : entries) { + Material material = Material.matchMaterial(entry.getKey()); + if (material == null) { + wreck.getContents().remove(entry.getKey()); + continue; + } + int added = addon.getHoldService().add(player, material, entry.getValue()); + moved += added; + if (added >= entry.getValue()) { + wreck.getContents().remove(entry.getKey()); + } else if (added > 0) { + wreck.getContents().put(entry.getKey(), entry.getValue() - added); + } + } + for (Map.Entry entry : new java.util.ArrayList<>(wreck.getFuel().entrySet())) { + Material material = Material.matchMaterial(entry.getKey()); + if (material == null) { + wreck.getFuel().remove(entry.getKey()); + continue; + } + int added = addon.getHoldService().addFuel(player, material, entry.getValue()); + moved += added; + if (added >= entry.getValue()) { + wreck.getFuel().remove(entry.getKey()); + } else if (added > 0) { + wreck.getFuel().put(entry.getKey(), entry.getValue() - added); + } + } + addon.getHoldManager().save(wreck); + return moved; + } + + /** + * Only prompt about the same hull every so often - item pickup fires + * every tick you stand near it. + */ + private boolean shouldPrompt(UUID playerId, String boatId) { + Map mine = prompted.computeIfAbsent(playerId, k -> new HashMap<>()); + long now = System.currentTimeMillis(); + Long last = mine.get(boatId); + if (last != null && now - last < PROMPT_SUPPRESSION_MS) { + return false; + } + mine.put(boatId, now); + return true; + } + + /** + * A tagged boat item never despawns on vanilla's clock - its own TTL owns + * its death. + */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onDespawn(ItemDespawnEvent event) { + if (BoatService.boatId(event.getEntity().getItemStack()) != null) { + event.setCancelled(true); + } + } + + // ------------------------------------------------------------ join/leave + + /** + * Report a boat taken while they were away, and hand a raft to anyone + * left treading water without one. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + if (!inOurWorlds(player.getWorld())) { + return; + } + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { + UUID id = player.getUniqueId(); + Optional active = addon.getHoldService().active(id); + boolean stolen = active.isPresent() && !id.toString().equals(active.get().getOwner()); + if (stolen || active.isEmpty()) { + if (stolen) { + // Cleared, NOT abandoned: it belongs to whoever took it, + // so it must not become their OLD BOAT (and must not have + // its new owner stripped, which used to hand it back) + addon.getHoldManager().clearActiveBoat(id); + User.getInstance(player).sendMessage("tradewinds.boat.stolen"); + } + if (player.getLocation().getBlock().isLiquid()) { + BoatHold raft = addon.getBoatService().createFor(player, respawnBoat()); + addon.getBoatService().giveBoatItem(player, raft); + User.getInstance(player).sendMessage("tradewinds.boat.adrift-raft", "[material]", + pretty(respawnBoat())); + } + } + }, 20L); + } + + private Material respawnBoat() { + Material material = Material.matchMaterial(String.valueOf(addon.getSettings().getRespawnBoat())); + return material != null && addon.getBoatRanks().slots(material) > 0 ? material : Material.BAMBOO_RAFT; + } + + // ---------------------------------------------------------------- sweep + + /** + * Remove expired ITEM-form boats. Boat entities never expire. + */ + public void sweepExpiredItems() { + long now = System.currentTimeMillis(); + for (BoatHold hold : addon.getHoldManager().allBoats()) { + if (hold.getExpiresAt() > 0 && hold.getExpiresAt() <= now) { + removeItemAvatar(hold); + forgetBoat(hold); + } + } + } + + private void removeItemAvatar(BoatHold hold) { + World world = hold.getWorld().isEmpty() ? null : Bukkit.getWorld(hold.getWorld()); + if (world == null || !world.isChunkLoaded(hold.getX() >> 4, hold.getZ() >> 4)) { + return; + } + Location where = new Location(world, hold.getX(), hold.getY(), hold.getZ()); + world.getNearbyEntities(where, 8, 8, 8).stream().filter(Item.class::isInstance).map(Item.class::cast) + .filter(item -> hold.getUniqueId().equals(BoatService.boatId(item.getItemStack()))) + .forEach(Item::remove); + } + + private boolean inOurWorlds(World world) { + return world.equals(addon.getOverWorld()) || world.equals(addon.getNetherWorld()); + } + + private static String pretty(Material material) { + return material == null ? "?" : world.bentobox.tradewinds.economy.PriceEngine.prettify(material.name()); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatPickupListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatPickupListener.java index 9fe93ab..7543b6c 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatPickupListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatPickupListener.java @@ -6,7 +6,6 @@ import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.entity.Boat; -import org.bukkit.entity.ChestBoat; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -50,17 +49,18 @@ public void onTeleport(PlayerTeleportEvent event) { player.leaveVehicle(); return; } - // Gather the hull and any cargo, then remove the entity + // Gather the hull, then remove the entity. The item MUST carry the + // boat's record id: without it a teleport quietly turns a ship into + // an anonymous hull and orphans its cargo. List items = new ArrayList<>(); - items.add(boatItem(boat)); - if (boat instanceof ChestBoat chestBoat) { - for (ItemStack stack : chestBoat.getInventory().getContents()) { - if (stack != null && !stack.getType().isAir()) { - items.add(stack.clone()); - } - } - chestBoat.getInventory().clear(); - } + ItemStack hull = boatItem(boat); + addon.getHoldManager().boat(BoatService.boatId(boat)).ifPresent(hold -> { + addon.getBoatService().stamp(hull, hold); + addon.getHoldManager().rememberPosition(hold, player.getLocation()); + }); + items.add(hull); + // A chest boat's REAL inventory is not the hold any more (the hold is + // the record), so there is nothing in there to rescue boat.eject(); boat.remove(); // Into the inventory now (it travels with the player); overflow drops diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatRanks.java b/src/main/java/world/bentobox/tradewinds/travel/BoatRanks.java new file mode 100644 index 0000000..dcdcfaa --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatRanks.java @@ -0,0 +1,109 @@ +package world.bentobox.tradewinds.travel; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.Material; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * The boat ladder: which boats exist as cargo ranks, how many hold slots each + * carries, and what the shop charges. Built from config + * ({@code boats.ranks}); rank order is slot order. The One Boat Rule means + * this ladder IS the cargo progression - a player's hold is exactly as big as + * the one boat they own. + * + * @author tastybento + */ +public class BoatRanks { + + /** + * One rung of the ladder. + * + * @param material the boat item/entity material + * @param rank 1-based position, smallest first + * @param slots hold cargo slots this boat provides + */ + public record Rank(Material material, int rank, int slots) { + } + + private final TradeWinds addon; + + public BoatRanks(TradeWinds addon) { + this.addon = addon; + } + + /** + * The ladder, smallest first, from config. Unknown material names are + * skipped (logged once by Settings validation, not here - this is called + * often). + * + * @return ranks in ascending slot order + */ + public List ladder() { + List ladder = new ArrayList<>(); + List> entries = new ArrayList<>( + addon.getSettings().getBoatRanks().entrySet()); + entries.sort(Comparator.comparingInt(Map.Entry::getValue)); + int rank = 1; + for (Map.Entry entry : entries) { + Material material = Material.matchMaterial(entry.getKey()); + if (material != null && entry.getValue() > 0) { + ladder.add(new Rank(material, rank++, entry.getValue())); + } + } + return ladder; + } + + /** + * The rank a boat material occupies, if it is on the ladder. + */ + public Optional rankOf(Material material) { + return ladder().stream().filter(r -> r.material() == material).findFirst(); + } + + /** + * Cargo slots for a boat material; 0 for null or off-ladder materials. + */ + public int slots(Material material) { + return material == null ? 0 : rankOf(material).map(Rank::slots).orElse(0); + } + + /** + * What the shop charges for a boat: quadratic in slots, so early rungs + * are pocket change and the top rungs cost real trading profit. + */ + public double price(Rank rank) { + return addon.getSettings().getBoatPricePerSlotSquared() * rank.slots() * rank.slots(); + } + + /** + * The rungs a shop at this tech level offers a player with this boat: + * strictly bigger than what they own (upgrades only - there is no + * downgrade and no second boat), and no higher than the island can build + * (rank <= tech x ranks-per-tech-level). Crafting bypasses all of this. + * + * @param current the player's boat, or null for none + * @param techLevel the island's tech level + * @return purchasable rungs, smallest first + */ + public List shopListing(Material current, int techLevel) { + int currentSlots = slots(current); + int maxRank = techLevel * addon.getSettings().getBoatRanksPerTechLevel(); + return ladder().stream() + .filter(r -> r.slots() > currentSlots) + .filter(r -> r.rank() <= maxRank) + .toList(); + } + + /** + * Whether a material is any boat or raft item - on the ladder or not. + */ + public static boolean isBoatItem(Material material) { + return material != null && (material.name().endsWith("_BOAT") || material.name().endsWith("_RAFT")); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java new file mode 100644 index 0000000..0ca88d0 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java @@ -0,0 +1,316 @@ +package world.bentobox.tradewinds.travel; + +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Boat; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.BoatHold; + +/** + * The physical half of a boat: the entity you ride and the item you carry are + * both avatars of one {@link BoatHold} record, keyed by a PDC id. This + * service creates them, keeps the name plate honest (owner's name, or + * UNOWNED), and moves ownership when a boat is claimed or abandoned. + * + * @author tastybento + */ +public class BoatService { + + /** PDC key carrying the BoatHold id on boat entities AND boat items. */ + public static final NamespacedKey BOAT_ID_KEY = NamespacedKey.fromString("tradewinds:boat-id"); + + private final TradeWinds addon; + + public BoatService(TradeWinds addon) { + this.addon = addon; + } + + // ------------------------------------------------------------ identities + + /** + * The BoatHold id stamped on an entity, or null. + */ + public static String boatId(Entity entity) { + if (entity == null || entity.getPersistentDataContainer() == null) { + return null; + } + return entity.getPersistentDataContainer().get(BOAT_ID_KEY, PersistentDataType.STRING); + } + + /** + * The BoatHold id stamped on an item, or null. + */ + public static String boatId(ItemStack stack) { + if (stack == null || !stack.hasItemMeta()) { + return null; + } + return stack.getItemMeta().getPersistentDataContainer().get(BOAT_ID_KEY, PersistentDataType.STRING); + } + + /** + * The record behind a boat entity, registering one on the spot for a + * plain vanilla boat (an unowned, empty hull - which is exactly what it + * is). + */ + public BoatHold recordFor(Boat boat) { + String id = boatId(boat); + Optional existing = addon.getHoldManager().boat(id); + if (existing.isPresent()) { + return existing.get(); + } + BoatHold hold = addon.getHoldManager().create(materialOf(boat), null); + stamp(boat, hold); + addon.getHoldManager().rememberPosition(hold, boat.getLocation()); + return hold; + } + + /** + * The record behind a boat item, if it has one. + */ + public Optional recordFor(ItemStack stack) { + return addon.getHoldManager().boat(boatId(stack)); + } + + public void stamp(Entity entity, BoatHold hold) { + entity.getPersistentDataContainer().set(BOAT_ID_KEY, PersistentDataType.STRING, hold.getUniqueId()); + entity.setPersistent(true); + label(entity, hold); + } + + /** + * Stamp a boat item with its record so the cargo rides along - and wear + * the manifest on the tin: cargo slots, fuel aboard, and how to open it. + */ + public ItemStack stamp(ItemStack stack, BoatHold hold) { + ItemMeta meta = stack.getItemMeta(); + if (meta != null) { + meta.getPersistentDataContainer().set(BOAT_ID_KEY, PersistentDataType.STRING, hold.getUniqueId()); + User console = User.getInstance(Bukkit.getConsoleSender()); + Material material = Material.matchMaterial(hold.getMaterial()); + meta.lore(java.util.List.of( + console.getTranslationAsComponent("tradewinds.item.boat-lore-cargo", + "[used]", String.valueOf(HoldService.slotsUsedIn(hold)), + "[slots]", String.valueOf(addon.getBoatRanks().slots(material))), + console.getTranslationAsComponent("tradewinds.item.boat-lore-fuel", + "[units]", String.format("%.0f", addon.getFuelService().unitsOf(hold))), + console.getTranslationAsComponent("tradewinds.item.boat-lore-open", new String[0]))); + stack.setItemMeta(meta); + } + return stack; + } + + /** + * Whether the player is carrying this boat as an item right now - in + * which case pointing a chart marker at it is pointing at their pocket. + * + * @param player the player + * @param hold the boat + * @return true if it is in their pack + */ + public boolean isCarrying(Player player, BoatHold hold) { + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && hold.getUniqueId().equals(boatId(stack))) { + return true; + } + } + return false; + } + + /** + * Re-write the lore of a carried avatar of this record, so the manifest + * stays true after the hold GUI closes. + */ + public void refreshItemLore(Player player, BoatHold hold) { + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && hold.getUniqueId().equals(boatId(stack))) { + stamp(stack, hold); + return; + } + } + } + + // ---------------------------------------------------------------- labels + + /** + * Put the owner's name over the hull - or UNOWNED, which is the sign a + * passer-by is actually looking for. Hidden while someone is aboard: the + * rider is the obvious answer. + */ + public void label(Entity entity, BoatHold hold) { + if (!(entity instanceof Boat boat)) { + return; + } + if (!boat.getPassengers().isEmpty()) { + boat.setCustomNameVisible(false); + return; + } + User console = User.getInstance(Bukkit.getConsoleSender()); + if (hold.isUnowned()) { + boat.customName(console.getTranslationAsComponent("tradewinds.boat.label-unowned", new String[0])); + } else { + String name = Bukkit.getOfflinePlayer(UUID.fromString(hold.getOwner())).getName(); + boat.customName(console.getTranslationAsComponent("tradewinds.boat.label-owned", "[name]", + name == null ? "?" : name)); + } + boat.setCustomNameVisible(true); + } + + /** + * Re-read the label of whatever avatar this record has in the world, if + * it is loaded. + */ + public void relabel(BoatHold hold) { + if (hold.getWorld() == null || hold.getWorld().isEmpty()) { + return; + } + org.bukkit.World world = Bukkit.getWorld(hold.getWorld()); + if (world == null) { + return; + } + Location where = new Location(world, hold.getX(), hold.getY(), hold.getZ()); + if (!world.isChunkLoaded(where.getBlockX() >> 4, where.getBlockZ() >> 4)) { + return; + } + world.getNearbyEntities(where, 4, 4, 4).stream() + .filter(Boat.class::isInstance) + .filter(e -> hold.getUniqueId().equals(boatId(e))) + .forEach(e -> label(e, hold)); + } + + // ------------------------------------------------------------- ownership + + /** + * Make this boat the player's active boat: they own it, and whatever they + * had becomes their unowned OLD BOAT wherever it lies. + */ + public void claim(Player player, BoatHold hold) { + Optional abandoned = addon.getHoldManager().activeBoat(player.getUniqueId()) + .filter(old -> !old.getUniqueId().equals(hold.getUniqueId())); + addon.getHoldManager().setActiveBoat(player.getUniqueId(), hold); + // Relabel AFTER the switch: setActiveBoat is what strips the old + // boat's owner, and relabelling before it kept the owner's name on + // the plate (playtest 2026-08-02: still "BoxManager's boat") + abandoned.ifPresent(this::relabel); + relabel(hold); + } + + /** + * Refit a boat into a bigger hull: the SAME record (so the cargo never + * moves), with the avatar in the world swapped to match. Whatever form + * the boat is in - ridden, carried, or moored alongside - has to change + * too, or the yard hands back a record that says Cherry Chest Boat over + * an oak hull. + * + * @param player the owner + * @param hold their boat + * @param material the hull they bought + */ + public void refit(Player player, BoatHold hold, Material material) { + hold.setMaterial(material.name()); + addon.getHoldManager().save(hold); + // Riding it: swap the vessel under them without a swim + if (player.getVehicle() instanceof Boat riding && hold.getUniqueId().equals(boatId(riding))) { + Location where = riding.getLocation(); + riding.eject(); + riding.remove(); + launch(player, where, hold); + return; + } + // Carried: the item becomes the new hull, keeping its identity + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && hold.getUniqueId().equals(boatId(stack))) { + stack.setType(material); + stamp(stack, hold); + return; + } + } + // Moored alongside: replace the hull where it floats + org.bukkit.World world = Bukkit.getWorld(hold.getWorld()); + if (world != null && world.isChunkLoaded(hold.getX() >> 4, hold.getZ() >> 4)) { + Location where = new Location(world, hold.getX(), hold.getY(), hold.getZ()); + world.getNearbyEntities(where, 6, 6, 6).stream().filter(Boat.class::isInstance) + .filter(e -> hold.getUniqueId().equals(boatId(e))).findFirst().ifPresent(old -> { + Location at = old.getLocation(); + old.remove(); + Material type = Material.matchMaterial(hold.getMaterial()); + org.bukkit.entity.EntityType entityType; + try { + entityType = org.bukkit.entity.EntityType + .valueOf(type == null ? "OAK_BOAT" : type.name()); + } catch (IllegalArgumentException e) { + entityType = org.bukkit.entity.EntityType.OAK_BOAT; + } + stamp(at.getWorld().spawnEntity(at, entityType), hold); + }); + } + } + + /** + * Give a player a brand new boat of this material as their active boat. + * + * @return the new record + */ + public BoatHold createFor(Player player, Material material) { + BoatHold hold = addon.getHoldManager().create(material, player.getUniqueId()); + addon.getHoldManager().setActiveBoat(player.getUniqueId(), hold); + return hold; + } + + /** + * Put a boat item in the player's pack (dropped at their feet if full), + * stamped with its record. + */ + public void giveBoatItem(Player player, BoatHold hold) { + Material material = Material.matchMaterial(hold.getMaterial()); + if (material == null) { + return; + } + ItemStack item = stamp(new ItemStack(material), hold); + addon.getHoldManager().rememberPosition(hold, player.getLocation()); + player.getInventory().addItem(item).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + } + + /** + * Spawn a boat entity for a record and seat the player in it. + */ + public Entity launch(Player player, Location where, BoatHold hold) { + Material material = Material.matchMaterial(hold.getMaterial()); + EntityType type; + try { + // Boat item materials and boat entity types share names + type = EntityType.valueOf(material == null ? "OAK_BOAT" : material.name()); + } catch (IllegalArgumentException e) { + type = EntityType.OAK_BOAT; + } + Entity boat = where.getWorld().spawnEntity(where, type); + stamp(boat, hold); + addon.getHoldManager().rememberPosition(hold, where); + boat.addPassenger(player); + return boat; + } + + /** + * The material a boat entity corresponds to. + */ + public static Material materialOf(Boat boat) { + try { + return Material.valueOf(boat.getType().name()); + } catch (IllegalArgumentException e) { + return Material.OAK_BOAT; + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java b/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java index c9c2aa3..42d3e8b 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java @@ -14,6 +14,7 @@ import org.bukkit.event.vehicle.VehicleMoveEvent; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.TWWarpCompletedEvent; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandSpec; @@ -35,6 +36,18 @@ public BorderPromptListener(TradeWinds addon) { this.addon = addon; } + /** + * A warp arrival lands at the island's border - on or near the offer ring, + * where the first oar stroke would cross it. Seed the cooldown so the + * dialog the sailor just warped through does not immediately reopen at the + * destination. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onWarpCompleted(TWWarpCompletedEvent event) { + lastPrompt.put(event.getPlayer().getUniqueId(), + event.getTo().cellX() + "," + event.getTo().cellZ() + "@" + System.currentTimeMillis() / 1000); + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onVehicleMove(VehicleMoveEvent event) { if (!(event.getVehicle() instanceof Boat boat) || boat.getPassengers().isEmpty() diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index 96ae5e4..b6a311c 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -35,14 +35,32 @@ public class ChartHolograms { /** Bearing sector width for stacking, degrees. */ private static final double SECTOR_DEGREES = 20.0; - private static final double BASE_HEIGHT = 1.2; - /** The dock marker hangs below the island names, clear of their stack. */ - private static final double DOCK_HEIGHT = 0.2; + private static final double BASE_HEIGHT = 2.5; + /** + * The dock marker hangs below the island names, clear of their stack, but + * never below the horizon: heights are relative to a sailor sitting at sea + * level, and at 0.2 the sign floated IN the water ten blocks out. + */ + private static final double DOCK_HEIGHT = 1.5; private static final double STACK_STEP = 0.8; + /** + * A boat closer than this needs no marker: an arrow pointing eight blocks + * at your own feet is noise, and a boat in your pack is not lost at all. + */ + private static final int MARKER_MIN_DISTANCE = 32; private static final int ZOOM_TICKS = 12; private final TradeWinds addon; private final Map> active = new ConcurrentHashMap<>(); + /** + * Which set of holograms is current per player. Each show() bumps it, and + * the fade timer only clears the generation it belongs to - otherwise the + * FIRST call's timer wipes the SECOND call's holograms seconds after they + * appear, which is exactly the "run it again and it vanishes" the + * 2026-08-02 playtest saw (arriving raised a set, /tw chart raised + * another, the older timer killed it). + */ + private final Map generation = new ConcurrentHashMap<>(); public ChartHolograms(TradeWinds addon) { this.addon = addon; @@ -57,6 +75,33 @@ public ChartHolograms(TradeWinds addon) { record Marker(double dx, double dy, double dz, IslandSpec island, int distance, boolean dock) { } + /** A marker pointing at a boat rather than an island. */ + record BoatMarker(double dx, double dy, double dz, int distance, boolean old) { + } + + /** + * Where your boat is, from anywhere in the world (ruled 2026-08-02): the + * active BOAT, and the OLD BOAT you abandoned by boarding another. Pure, + * so the bearing is testable. + * + * @param boatX boat block x + * @param boatZ boat block z + * @param px player block x + * @param pz player block z + * @param radius ring radius in blocks + * @param old true for the abandoned boat + * @return the marker + */ + static BoatMarker boatMarker(int boatX, int boatZ, int px, int pz, double radius, boolean old) { + double dx = (double) boatX - px; + double dz = (double) boatZ - pz; + double dist = Math.max(1.0, Math.hypot(dx, dz)); + // Above the dock marker, below the island stack: your own ship is the + // second thing you look for after the quay + return new BoatMarker(dx / dist * radius, old ? DOCK_HEIGHT + 0.6 : DOCK_HEIGHT + 1.2, + dz / dist * radius, (int) dist, old); + } + /** * The marker pointing at the pier of the island you are currently in the * waters of, if you are in any. @@ -115,6 +160,21 @@ static List markers(List islands, int px, int pz, double rad return result; } + /** + * Whether a boat is far enough away - and out of hand - to be worth a + * marker at all. + */ + private boolean worthMarking(Player player, world.bentobox.tradewinds.dataobjects.BoatHold hold, + Location eye) { + if (!hold.getWorld().equals(player.getWorld().getName()) + || addon.getBoatService().isCarrying(player, hold)) { + return false; + } + double dx = (double) hold.getX() - eye.getBlockX(); + double dz = (double) hold.getZ() - eye.getBlockZ(); + return dx * dx + dz * dz > (double) MARKER_MIN_DISTANCE * MARKER_MIN_DISTANCE; + } + /** * Show the compass to a player: clears any previous set, spawns the * holograms at the player and zooms them out to the ring. @@ -138,10 +198,48 @@ public void show(Player player) { eye.getBlockZ(), radius))); markers.addAll(markers(chartedIslands(player), eye.getBlockX(), eye.getBlockZ(), radius, addon.getSettings().getChartHologramMax())); - if (markers.isEmpty()) { + List boats = new ArrayList<>(); + // Only mark a boat you have to GO to: not the one under you, not the + // one in your pack, and not one a few paces away (2026-08-02: both + // markers pointed 8m at the player's own feet). + if (!(player.getVehicle() instanceof org.bukkit.entity.Boat)) { + addon.getHoldManager().activeBoat(player.getUniqueId()) + .filter(hold -> worthMarking(player, hold, eye)) + .ifPresent(hold -> boats.add(boatMarker(hold.getX(), hold.getZ(), eye.getBlockX(), + eye.getBlockZ(), radius, false))); + } + addon.getHoldManager().oldBoat(player.getUniqueId()) + .filter(hold -> worthMarking(player, hold, eye)) + .ifPresent(hold -> boats.add(boatMarker(hold.getX(), hold.getZ(), eye.getBlockX(), + eye.getBlockZ(), radius, true))); + if (markers.isEmpty() && boats.isEmpty()) { return; } List spawned = new ArrayList<>(); + for (BoatMarker boat : boats) { + TextDisplay display = eye.getWorld().spawn(eye.clone().add(0, 1.0, 0), TextDisplay.class); + display.text(User.getInstance(player).getTranslationAsComponent( + boat.old() ? "tradewinds.hologram.old-boat" : "tradewinds.hologram.boat", + "[distance]", String.valueOf(boat.distance()))); + display.setBillboard(Billboard.CENTER); + display.setSeeThrough(true); + display.setBackgroundColor(boat.old() ? Color.fromARGB(140, 60, 0, 0) + : Color.fromARGB(140, 0, 60, 30)); + display.setPersistent(false); + display.setTeleportDuration(ZOOM_TICKS); + for (Player other : Bukkit.getOnlinePlayers()) { + if (!other.equals(player)) { + other.hideEntity(addon.getPlugin(), display); + } + } + spawned.add(display); + Location target = eye.clone().add(boat.dx(), boat.dy(), boat.dz()); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { + if (display.isValid()) { + display.teleport(target); + } + }); + } for (Marker marker : markers) { TextDisplay display = eye.getWorld().spawn(eye.clone().add(0, 1.0, 0), TextDisplay.class); display.text(label(player, marker)); @@ -167,9 +265,14 @@ public void show(Player player) { }); } active.put(player.getUniqueId(), spawned); - // Fade out after the configured time - Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> clear(player.getUniqueId()), - addon.getSettings().getChartHologramSeconds() * 20L); + int mine = generation.merge(player.getUniqueId(), 1, Integer::sum); + // Fade out after the configured time - but only if nothing newer has + // replaced this set in the meantime + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { + if (generation.getOrDefault(player.getUniqueId(), 0) == mine) { + clear(player.getUniqueId()); + } + }, addon.getSettings().getChartHologramSeconds() * 20L); } /** @@ -195,6 +298,7 @@ private Component label(Player player, Marker marker) { return User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.island", "[name]", spec.name(), "[type]", spec.type().name(), + "[tech]", String.valueOf(spec.techLevel()), "[band]", User.getInstance(player).getTranslation(spec.band().getLocaleKey()), "[distance]", String.valueOf(marker.distance())); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java b/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java deleted file mode 100644 index a903f16..0000000 --- a/src/main/java/world/bentobox/tradewinds/travel/ExpanderListener.java +++ /dev/null @@ -1,108 +0,0 @@ -package world.bentobox.tradewinds.travel; - -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.Sound; -import org.bukkit.block.ShulkerBox; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; -import org.bukkit.event.block.Action; -import org.bukkit.event.inventory.InventoryClickEvent; -import org.bukkit.event.inventory.InventoryCloseEvent; -import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.BlockStateMeta; - -import world.bentobox.tradewinds.TradeWinds; - -/** - * Opens a cargo expander in the hand. Java Edition cannot open a shulker box - * from the inventory, so without this the carried hold could never be filled - * or emptied by hand - the whole point of carrying it. Right-click opens the - * expander's contents; closing writes them back into the item. - *

- * Expanders are never placed as blocks: they are cargo, and a placed one - * would be a chest anyone could rob. - * - * @author tastybento - */ -public class ExpanderListener implements Listener { - - private final TradeWinds addon; - /** Player -> hotbar slot of the expander they have open. */ - private final Map open = new HashMap<>(); - - public ExpanderListener(TradeWinds addon) { - this.addon = addon; - } - - @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) - public void onRightClick(PlayerInteractEvent event) { - if (event.getHand() != EquipmentSlot.HAND - || (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) - || !addon.getHoldService().isExpander(event.getItem())) { - return; - } - // Never place it as a block - it is cargo - event.setCancelled(true); - Player player = event.getPlayer(); - ItemStack expander = event.getItem(); - if (!(expander.getItemMeta() instanceof BlockStateMeta meta) - || !(meta.getBlockState() instanceof ShulkerBox box)) { - return; - } - Inventory view = Bukkit.createInventory(null, 27, world.bentobox.bentobox.api.user.User - .getInstance(player).getTranslationAsComponent("tradewinds.item.expander-view", new String[0])); - view.setContents(box.getInventory().getContents()); - open.put(player.getUniqueId(), player.getInventory().getHeldItemSlot()); - player.openInventory(view); - player.playSound(player.getLocation(), Sound.BLOCK_SHULKER_BOX_OPEN, 0.6f, 1.2f); - } - - /** - * No nesting: an expander cannot be stowed inside another. - */ - @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) - public void onClick(InventoryClickEvent event) { - if (!open.containsKey(event.getWhoClicked().getUniqueId())) { - return; - } - ItemStack moving = event.getCurrentItem() != null && event.getClick().isShiftClick() ? event.getCurrentItem() - : event.getCursor(); - if (addon.getHoldService().isExpander(moving)) { - event.setCancelled(true); - } - } - - @EventHandler(priority = EventPriority.MONITOR) - public void onClose(InventoryCloseEvent event) { - Integer slot = open.remove(event.getPlayer().getUniqueId()); - if (slot == null || !(event.getPlayer() instanceof Player player)) { - return; - } - ItemStack expander = player.getInventory().getItem(slot); - if (!addon.getHoldService().isExpander(expander) - || !(expander.getItemMeta() instanceof BlockStateMeta meta) - || !(meta.getBlockState() instanceof ShulkerBox box)) { - // They swapped the item away mid-edit: give the contents back - event.getInventory().forEach(stack -> { - if (stack != null) { - player.getWorld().dropItem(player.getLocation(), stack); - } - }); - return; - } - box.getInventory().setContents(event.getInventory().getContents()); - meta.setBlockState(box); - expander.setItemMeta(meta); - player.getInventory().setItem(slot, expander); - player.playSound(player.getLocation(), Sound.BLOCK_SHULKER_BOX_CLOSE, 0.6f, 1.2f); - } -} diff --git a/src/main/java/world/bentobox/tradewinds/travel/FuelService.java b/src/main/java/world/bentobox/tradewinds/travel/FuelService.java index 248ff6d..dac1ba3 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/FuelService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/FuelService.java @@ -6,21 +6,15 @@ import java.util.Map; import org.bukkit.Material; -import org.bukkit.entity.ChestBoat; import org.bukkit.entity.Player; -import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import world.bentobox.tradewinds.TradeWinds; /** - * Warp fuel accounting. Fuel lives ONLY in the hold - the chest boat's - * inventory plus trading bundles carried by the player (spec principle 1: - * nothing gameplay-relevant may bypass the hold). Plain inventory items in the - * player's pockets do not count. - *

- * Stage 3 hold definition: chest-boat inventory + bundle contents. Stage 4 - * extends the same accessor to cargo expanders. + * Warp fuel accounting. Fuel lives ONLY in the hold's seven fuel slots (spec + * principle 1: nothing gameplay-relevant may bypass the hold) - the virtual + * record, not any real container. Pocket items never count. * * @author tastybento */ @@ -33,123 +27,66 @@ public FuelService(TradeWinds addon) { } /** - * One consumable stack found in the hold, with a handle to remove from the - * right container. + * The fuel unit value of a material, or 0 if it is not fuel. */ - private record FuelStack(ItemStack stack, double unitValue, Runnable shrinkOne) { + public double fuelValue(Material material) { + return addon.getSettings().getFuelValues().getOrDefault(material.name(), 0.0); } /** - * The fuel unit value of a material, or 0 if it is not fuel. + * Total fuel units aboard one boat record. */ - public double fuelValue(Material material) { - return addon.getSettings().getFuelValues().getOrDefault(material.name(), 0.0); + public double unitsOf(world.bentobox.tradewinds.dataobjects.BoatHold hold) { + return HoldService.fuelOf(hold).entrySet().stream() + .mapToDouble(e -> fuelValue(e.getKey()) * e.getValue()).sum(); } /** * Total fuel units aboard. * - * @param player the (boated) player - * @return fuel units in the hold + * @param player the player + * @return fuel units in the hold's fuel slots */ public double holdFuel(Player player) { - return holdStacks(player).stream().mapToDouble(f -> f.unitValue() * f.stack().getAmount()).sum(); + return addon.getHoldService().fuelContents(player.getUniqueId()).entrySet().stream() + .mapToDouble(e -> fuelValue(e.getKey()) * e.getValue()).sum(); } /** - * Consume at least {@code units} of fuel from the hold, cheapest items - * first. Overshoot is burned - warping is not a precise science. Lava - * buckets leave their empty bucket behind. + * Consume at least {@code units} of fuel, cheapest items first. Overshoot + * is burned - warping is not a precise science. Lava buckets leave their + * empty bucket in the player's pack (an empty bucket is a tool, not + * cargo). * * @param player the player * @param units fuel units required - * @return true if consumed; false (nothing removed) if the hold has too little + * @return true if consumed; false (nothing removed) if too little aboard */ public boolean consume(Player player, double units) { if (holdFuel(player) < units) { return false; } + HoldService holdService = addon.getHoldService(); double remaining = units; // Cheapest first: burn wood before coal before lava - List stacks = holdStacks(player); - stacks.sort(Comparator.comparingDouble(FuelStack::unitValue)); - for (FuelStack fuel : stacks) { - while (remaining > 0 && fuel.stack().getAmount() > 0) { - fuel.shrinkOne().run(); - remaining -= fuel.unitValue(); - } + List> stacks = new ArrayList<>( + holdService.fuelContents(player.getUniqueId()).entrySet()); + stacks.sort(Comparator.comparingDouble(e -> fuelValue(e.getKey()))); + for (Map.Entry entry : stacks) { if (remaining <= 0) { break; } - } - return true; - } - - /** - * Every fuel stack in the hold: chest-boat inventory plus bundles in the - * player's inventory. - */ - private List holdStacks(Player player) { - List result = new ArrayList<>(); - if (player.getVehicle() instanceof ChestBoat boat) { - Inventory inv = boat.getInventory(); - for (ItemStack stack : inv.getContents()) { - addIfFuel(result, stack, () -> shrinkInInventory(inv, stack)); - } - } - // Bundles anywhere in the player inventory count as hold space - for (ItemStack carried : player.getInventory().getContents()) { - if (carried != null && carried.getItemMeta() instanceof org.bukkit.inventory.meta.BundleMeta bundle) { - for (ItemStack inside : bundle.getItems()) { - addIfFuel(result, inside, () -> shrinkInBundle(carried, inside)); - } + double value = fuelValue(entry.getKey()); + int needed = (int) Math.ceil(remaining / value); + int burned = holdService.removeFuel(player.getUniqueId(), entry.getKey(), + Math.min(needed, entry.getValue())); + remaining -= burned * value; + if (entry.getKey() == Material.LAVA_BUCKET && burned > 0) { + ItemStack buckets = new ItemStack(Material.BUCKET, burned); + player.getInventory().addItem(buckets).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); } } - return result; - } - - private void addIfFuel(List result, ItemStack stack, Runnable shrinkOne) { - if (stack == null || stack.getType().isAir()) { - return; - } - double value = fuelValue(stack.getType()); - if (value > 0) { - result.add(new FuelStack(stack, value, shrinkOne)); - } - } - - private void shrinkInInventory(Inventory inv, ItemStack stack) { - if (stack.getType() == Material.LAVA_BUCKET) { - stack.setType(Material.BUCKET); - } else { - stack.setAmount(stack.getAmount() - 1); - if (stack.getAmount() <= 0) { - inv.remove(stack); - } - } - } - - private void shrinkInBundle(ItemStack bundleItem, ItemStack inside) { - if (!(bundleItem.getItemMeta() instanceof org.bukkit.inventory.meta.BundleMeta bundle)) { - return; - } - List items = new ArrayList<>(bundle.getItems()); - for (ItemStack item : items) { - if (item.equals(inside) || item.isSimilar(inside)) { - if (item.getType() == Material.LAVA_BUCKET) { - item.setType(Material.BUCKET); - inside.setType(Material.BUCKET); - } else { - item.setAmount(item.getAmount() - 1); - inside.setAmount(Math.max(0, inside.getAmount() - 1)); - if (item.getAmount() <= 0) { - items.remove(item); - } - } - break; - } - } - bundle.setItems(items); - bundleItem.setItemMeta(bundle); + return true; } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java new file mode 100644 index 0000000..0a11206 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java @@ -0,0 +1,597 @@ +package world.bentobox.tradewinds.travel; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Boat; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; + +/** + * The hold GUI: a 54-slot window onto the player's VIRTUAL hold. The + * inventory shown is a rendering, never storage - every click is cancelled + * and interpreted as a command, so no inventory trick can extract cargo: + *

    + *
  • click an item in your pack → deposit it (fuel routes to the fuel row, + * containers and vessels are refused, cargo is one-way),
  • + *
  • click a cargo stack → select it; click the TNT slot → destroy it,
  • + *
  • click a fuel stack → take it back (fuel moves freely both ways),
  • + *
  • click an installed expander → its own nested 21-slot panel (Pale Oak + * Chest Boat only; elsewhere it rides along inert). Right-click an EMPTY + * expander to select it for the TNT.
  • + *
+ * Opening: sneak-right-click the boat entity; right-click while riding + * (empty hand); right-click the boat item in your inventory screen; and on + * chest-variant boats, simply opening your inventory while riding (the + * vanilla chest-boat gesture - plain boats cannot send that signal). + * Layout (6 rows): border panes; TNT destroy slot top-left; 21 cargo slots + * (locked ones render as panes until the boat rank unlocks them); a divider; + * 7 fuel slots. The nested expander panel is the same idea without the fuel + * row. + * + * @author tastybento + */ +public class HoldGui implements Listener { + + private static final int SIZE = 54; + private static final int NESTED_SIZE = 45; + private static final int TNT_SLOT = 1; + /** Cargo slots: rows 1-3, columns 1-7 (both windows). */ + private static final int[] CARGO = new int[HoldService.MAX_CARGO_SLOTS]; + /** Fuel slots: row 5, columns 1-7 (main window only). */ + private static final int[] FUEL = new int[HoldService.FUEL_SLOTS]; + static { + int i = 0; + for (int row = 1; row <= 3; row++) { + for (int col = 1; col <= 7; col++) { + CARGO[i++] = row * 9 + col; + } + } + for (int col = 1; col <= 7; col++) { + FUEL[col - 1] = 5 * 9 + col; + } + } + + /** + * A selection waiting for the TNT: a cargo stack (material + amount) or + * an empty expander (expanderIndex >= 0, material null). + */ + private record Selection(Material material, int amount, int expanderIndex) { + } + + /** A nested expander window. */ + private record Nested(Inventory inventory, int index) { + } + + private final TradeWinds addon; + private final Map open = new HashMap<>(); + private final Map selected = new HashMap<>(); + private final Map nested = new HashMap<>(); + private final Map nestedSelected = new HashMap<>(); + + public HoldGui(TradeWinds addon) { + this.addon = addon; + } + + /** + * Open the hold window. + */ + public void open(Player player) { + Inventory inv = Bukkit.createInventory(null, SIZE, + User.getInstance(player).getTranslationAsComponent("tradewinds.hold.title", "[slots]", + String.valueOf(addon.getHoldService().capacitySlots(player)))); + render(player, inv); + player.openInventory(inv); + open.put(player.getUniqueId(), inv); + player.playSound(player.getLocation(), Sound.BLOCK_BARREL_OPEN, 0.6f, 1.1f); + } + + /** + * Open one expander's nested panel. + */ + private void openNested(Player player, int index) { + Inventory inv = Bukkit.createInventory(null, NESTED_SIZE, + User.getInstance(player).getTranslationAsComponent("tradewinds.hold.expander-title", + "[number]", String.valueOf(index + 1))); + renderNested(player, inv, index); + player.openInventory(inv); + nested.put(player.getUniqueId(), new Nested(inv, index)); + } + + private void render(Player player, Inventory inv) { + HoldService hold = addon.getHoldService(); + User user = User.getInstance(player); + UUID id = player.getUniqueId(); + ItemStack border = pane(Material.LIGHT_BLUE_STAINED_GLASS_PANE, user, "tradewinds.hold.border", null); + for (int slot = 0; slot < SIZE; slot++) { + inv.setItem(slot, border); + } + inv.setItem(TNT_SLOT, tnt(user)); + // Cargo: consolidated stacks, then installed expanders, then space + List stacks = stacksOf(hold.contents(id)); + int expanders = hold.expanderCount(id); + int capacity = hold.capacitySlots(player); + for (int i = 0; i < CARGO.length; i++) { + if (i < stacks.size()) { + inv.setItem(CARGO[i], stacks.get(i)); + } else if (i < stacks.size() + expanders) { + inv.setItem(CARGO[i], expanderItem(user, id, i - stacks.size())); + } else if (i < capacity) { + inv.setItem(CARGO[i], null); + } else { + inv.setItem(CARGO[i], pane(Material.GRAY_STAINED_GLASS_PANE, user, "tradewinds.hold.locked", + "tradewinds.hold.locked-lore")); + } + } + // Fuel row + List fuelStacks = stacksOf(hold.fuelContents(id)); + for (int i = 0; i < FUEL.length; i++) { + inv.setItem(FUEL[i], i < fuelStacks.size() ? fuelStacks.get(i) + : pane(Material.PINK_STAINED_GLASS_PANE, user, "tradewinds.hold.fuel-slot", + "tradewinds.hold.fuel-slot-lore")); + } + } + + private void renderNested(Player player, Inventory inv, int index) { + HoldService hold = addon.getHoldService(); + User user = User.getInstance(player); + ItemStack border = pane(Material.WHITE_STAINED_GLASS_PANE, user, "tradewinds.hold.border", null); + for (int slot = 0; slot < NESTED_SIZE; slot++) { + inv.setItem(slot, border); + } + inv.setItem(TNT_SLOT, tnt(user)); + List stacks = stacksOf(hold.expanderContents(player.getUniqueId(), index)); + for (int i = 0; i < HoldService.EXPANDER_SLOTS; i++) { + inv.setItem(CARGO[i], i < stacks.size() ? stacks.get(i) : null); + } + } + + private ItemStack tnt(User user) { + ItemStack tnt = new ItemStack(Material.TNT); + ItemMeta meta = tnt.getItemMeta(); + meta.displayName(user.getTranslationAsComponent("tradewinds.hold.tnt", new String[0])); + meta.lore(List.of(user.getTranslationAsComponent("tradewinds.hold.tnt-lore", new String[0]))); + tnt.setItemMeta(meta); + return tnt; + } + + private ItemStack expanderItem(User user, UUID id, int index) { + ItemStack item = new ItemStack(Material.WHITE_SHULKER_BOX); + ItemMeta meta = item.getItemMeta(); + meta.displayName(user.getTranslationAsComponent("tradewinds.hold.expander", + "[number]", String.valueOf(index + 1))); + String loreKey = addon.getHoldService().expandersOpenable(id) ? "tradewinds.hold.expander-lore" + : "tradewinds.hold.expander-inert-lore"; + meta.lore(List.of(user.getTranslationAsComponent(loreKey, new String[0]))); + item.setItemMeta(meta); + return item; + } + + /** + * A contents map as max-size stacks, display order preserved. + */ + private static List stacksOf(Map contents) { + List stacks = new ArrayList<>(); + contents.forEach((material, amount) -> { + int left = amount; + int max = Math.max(1, material.getMaxStackSize()); + while (left > 0) { + int size = Math.min(left, max); + stacks.add(new ItemStack(material, size)); + left -= size; + } + }); + return stacks; + } + + private ItemStack pane(Material material, User user, String nameKey, String loreKey) { + ItemStack pane = new ItemStack(material); + ItemMeta meta = pane.getItemMeta(); + meta.displayName(user.getTranslationAsComponent(nameKey, new String[0])); + if (loreKey != null) { + meta.lore(List.of(user.getTranslationAsComponent(loreKey, new String[0]))); + } + pane.setItemMeta(meta); + return pane; + } + + // ------------------------------------------------------------ open gestures + + /** + * Riding your boat, empty main hand, right-click: open the hold. + */ + @EventHandler(priority = EventPriority.LOW) + public void onRightClickRiding(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND + || (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) + || event.useItemInHand() == org.bukkit.event.Event.Result.DENY + || (event.getItem() != null && !event.getItem().getType().isAir()) + || !(event.getPlayer().getVehicle() instanceof Boat)) { + return; + } + event.setCancelled(true); + open(event.getPlayer()); + } + + /** + * Sneak + right-click a boat entity: open the hold instead of mounting. + */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onSneakClickBoat(PlayerInteractEntityEvent event) { + if (event.getHand() != EquipmentSlot.HAND || !(event.getRightClicked() instanceof Boat) + || !event.getPlayer().isSneaking()) { + return; + } + event.setCancelled(true); + open(event.getPlayer()); + } + + /** + * Pressing the inventory key while riding a CHEST-variant boat: the + * client asks the server to open the vehicle's chest, and the hold opens + * instead - exactly the vanilla chest-boat gesture, showing the hold on + * top and the player's inventory below. (Plain boats never send that + * packet - the client draws the ordinary inventory entirely on its own - + * so for them the in-boat right-click and the boat-item click serve.) + * This also closes the last route to a chest boat's REAL inventory: no + * shadow storage. + */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onVehicleInventoryOpen(org.bukkit.event.inventory.InventoryOpenEvent event) { + if (!(event.getPlayer() instanceof Player player) || !(player.getVehicle() instanceof Boat) + || !(event.getInventory().getHolder() instanceof org.bukkit.entity.ChestBoat)) { + return; + } + event.setCancelled(true); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> open(player)); + } + + // ---------------------------------------------------------------- windows + + @EventHandler(priority = EventPriority.LOW) + public void onClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + UUID id = player.getUniqueId(); + Inventory top = event.getView().getTopInventory(); + Nested nestedView = nested.get(id); + if (nestedView != null && top.equals(nestedView.inventory())) { + event.setCancelled(true); + Inventory clicked = event.getClickedInventory(); + if (clicked != null && clicked != top) { + depositNested(player, event.getCurrentItem(), nestedView.index()); + } else if (clicked == top) { + nestedTopClick(player, event.getSlot(), event.getCurrentItem(), nestedView.index()); + } + renderNested(player, nestedView.inventory(), nestedView.index()); + return; + } + if (top.equals(open.get(id))) { + event.setCancelled(true); + Inventory clicked = event.getClickedInventory(); + if (clicked != null && clicked != top) { + deposit(player, event.getCurrentItem()); + } else if (clicked == top) { + topClick(player, event.getSlot(), event.getCurrentItem(), event.getClick()); + } + render(player, top); + return; + } + boatItemGesture(event, player); + } + + /** + * Right-clicking your boat item in the inventory screen opens the hold - + * the bundle-like gesture players reach for first. + */ + private void boatItemGesture(InventoryClickEvent event, Player player) { + ItemStack item = event.getCurrentItem(); + if (event.getClick() != ClickType.RIGHT || item == null + || (event.getCursor() != null && !event.getCursor().getType().isAir()) + || event.getClickedInventory() != player.getInventory()) { + return; + } + // By IDENTITY, not by type: matching on material meant any oak boat in + // the pack opened the hold of whatever oak boat you owned (2026-08-02) + String clickedId = BoatService.boatId(item); + var active = addon.getHoldService().active(player.getUniqueId()); + boolean mine = active.map(hold -> hold.getUniqueId().equals(clickedId)).orElse(false); + if (!mine && clickedId == null && active.isPresent() + && item.getType() == Material.matchMaterial(active.get().getMaterial()) + && !addon.getBoatService().isCarrying(player, active.get())) { + // An unstamped hull of exactly our boat's type, and no other + // avatar of it in the pack: adopt it. Self-heals a hull that + // reached the player without its record - a crafted boat left on + // the cursor did exactly that (2026-08-02), and such an item can + // otherwise never be opened at all. + addon.getBoatService().stamp(item, active.get()); + mine = true; + } + if (!mine) { + return; + } + event.setCancelled(true); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> open(player)); + } + + private void deposit(Player player, ItemStack stack) { + if (stack == null || stack.getType().isAir()) { + return; + } + HoldService hold = addon.getHoldService(); + Material material = stack.getType(); + User user = User.getInstance(player); + if (addon.getFuelService().fuelValue(material) > 0) { + int added = hold.addFuel(player, material, stack.getAmount()); + if (added <= 0) { + user.sendMessage("tradewinds.hold.fuel-full"); + thud(player); + } else { + stack.setAmount(stack.getAmount() - added); + chime(player); + } + return; + } + if (hold.refuses(material)) { + user.sendMessage(BoatRanks.isBoatItem(material) ? "tradewinds.hold.boat-refused" + : "tradewinds.hold.container-refused"); + thud(player); + return; + } + int added = hold.add(player, material, stack.getAmount()); + if (added <= 0) { + user.sendMessage("tradewinds.hold.cargo-full"); + thud(player); + } else { + stack.setAmount(stack.getAmount() - added); + chime(player); + } + } + + /** + * Deposits while a nested panel is open: fuel still routes to the main + * fuel row (fuel dropped anywhere in the GUI finds a fuel slot); cargo + * goes into THIS expander. + */ + private void depositNested(Player player, ItemStack stack, int index) { + if (stack == null || stack.getType().isAir()) { + return; + } + HoldService hold = addon.getHoldService(); + Material material = stack.getType(); + User user = User.getInstance(player); + if (addon.getFuelService().fuelValue(material) > 0) { + int added = hold.addFuel(player, material, stack.getAmount()); + if (added <= 0) { + user.sendMessage("tradewinds.hold.fuel-full"); + thud(player); + } else { + stack.setAmount(stack.getAmount() - added); + chime(player); + } + return; + } + if (hold.refuses(material)) { + user.sendMessage(BoatRanks.isBoatItem(material) ? "tradewinds.hold.boat-refused" + : "tradewinds.hold.container-refused"); + thud(player); + return; + } + int added = hold.addToExpander(player, index, material, stack.getAmount()); + if (added <= 0) { + user.sendMessage("tradewinds.hold.cargo-full"); + thud(player); + } else { + stack.setAmount(stack.getAmount() - added); + chime(player); + } + } + + private void topClick(Player player, int slot, ItemStack shown, ClickType click) { + User user = User.getInstance(player); + UUID id = player.getUniqueId(); + HoldService hold = addon.getHoldService(); + if (slot == TNT_SLOT) { + destroySelection(player, selected.remove(id)); + return; + } + for (int i = 0; i < FUEL.length; i++) { + if (FUEL[i] == slot) { + withdrawFuel(player, shown); + return; + } + } + int index = cargoIndex(slot); + if (index < 0 || shown == null || shown.getType().isAir() + || shown.getType().name().endsWith("STAINED_GLASS_PANE")) { + selected.remove(id); + return; + } + int stackCount = stacksOf(hold.contents(id)).size(); + if (index >= stackCount && index < stackCount + hold.expanderCount(id)) { + int expander = index - stackCount; + if (click == ClickType.RIGHT) { + // Select an EMPTY expander for the TNT + if (hold.expanderContents(id, expander).isEmpty()) { + selected.put(id, new Selection(null, 0, expander)); + user.sendMessage("tradewinds.hold.expander-selected", "[number]", + String.valueOf(expander + 1)); + } else { + user.sendMessage("tradewinds.hold.expander-not-empty"); + thud(player); + } + return; + } + if (!hold.expandersOpenable(id)) { + user.sendMessage("tradewinds.hold.expander-inert"); + thud(player); + return; + } + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> openNested(player, expander)); + return; + } + if (addon.getFuelService().fuelValue(shown.getType()) > 0) { + // Fuel-valued cargo (e.g. coal bought at market) moves to the + // fuel row on click - fuel is exempt from the one-way rule + moveCargoFuel(player, shown); + return; + } + selected.put(id, new Selection(shown.getType(), shown.getAmount(), -1)); + user.sendMessage("tradewinds.hold.selected", "[amount]", String.valueOf(shown.getAmount()), + "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + } + + private void moveCargoFuel(Player player, ItemStack shown) { + int moved = addon.getHoldService().moveCargoToFuel(player, shown.getType(), shown.getAmount()); + User user = User.getInstance(player); + if (moved > 0) { + user.sendMessage("tradewinds.hold.moved-to-fuel", "[amount]", String.valueOf(moved), + "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + chime(player); + } else { + user.sendMessage("tradewinds.hold.fuel-full"); + thud(player); + } + } + + private void nestedTopClick(Player player, int slot, ItemStack shown, int index) { + User user = User.getInstance(player); + UUID id = player.getUniqueId(); + if (slot == TNT_SLOT) { + Selection selection = nestedSelected.remove(id); + if (selection == null || selection.material() == null) { + user.sendMessage("tradewinds.hold.select-first"); + return; + } + int destroyed = addon.getHoldService().removeFromExpander(id, index, selection.material(), + selection.amount()); + user.sendMessage("tradewinds.hold.destroyed", "[amount]", String.valueOf(destroyed), "[material]", + world.bentobox.tradewinds.economy.PriceEngine.prettify(selection.material().name())); + player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 0.4f, 1.4f); + return; + } + if (cargoIndex(slot) < 0 || shown == null || shown.getType().isAir() + || shown.getType().name().endsWith("STAINED_GLASS_PANE")) { + nestedSelected.remove(id); + return; + } + if (addon.getFuelService().fuelValue(shown.getType()) > 0) { + moveCargoFuel(player, shown); + return; + } + nestedSelected.put(id, new Selection(shown.getType(), shown.getAmount(), -1)); + user.sendMessage("tradewinds.hold.selected", "[amount]", String.valueOf(shown.getAmount()), + "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + } + + private void destroySelection(Player player, Selection selection) { + User user = User.getInstance(player); + if (selection == null) { + user.sendMessage("tradewinds.hold.select-first"); + return; + } + if (selection.expanderIndex() >= 0) { + if (addon.getHoldService().destroyExpander(player.getUniqueId(), selection.expanderIndex())) { + user.sendMessage("tradewinds.hold.expander-destroyed"); + player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 0.4f, 1.4f); + } else { + user.sendMessage("tradewinds.hold.expander-not-empty"); + thud(player); + } + return; + } + int destroyed = addon.getHoldService().remove(player, selection.material(), selection.amount()); + user.sendMessage("tradewinds.hold.destroyed", "[amount]", String.valueOf(destroyed), "[material]", + world.bentobox.tradewinds.economy.PriceEngine.prettify(selection.material().name())); + player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 0.4f, 1.4f); + } + + private static int cargoIndex(int slot) { + for (int i = 0; i < CARGO.length; i++) { + if (CARGO[i] == slot) { + return i; + } + } + return -1; + } + + private void withdrawFuel(Player player, ItemStack shown) { + if (shown == null || shown.getType().isAir() || shown.getType().name().endsWith("STAINED_GLASS_PANE")) { + return; + } + int taken = addon.getHoldService().removeFuel(player.getUniqueId(), shown.getType(), shown.getAmount()); + if (taken > 0) { + player.getInventory().addItem(new ItemStack(shown.getType(), taken)).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + chime(player); + } + } + + @EventHandler(priority = EventPriority.LOW) + public void onDrag(InventoryDragEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + UUID id = player.getUniqueId(); + Inventory top = event.getView().getTopInventory(); + Nested nestedView = nested.get(id); + boolean ours = top.equals(open.get(id)) + || (nestedView != null && top.equals(nestedView.inventory())); + // Any drag touching a hold window is refused - deposits are clicks + if (ours && event.getRawSlots().stream().anyMatch(raw -> raw < top.getSize())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onClose(InventoryCloseEvent event) { + UUID id = event.getPlayer().getUniqueId(); + Inventory closing = event.getInventory(); + Nested nestedView = nested.get(id); + if (nestedView != null && closing.equals(nestedView.inventory())) { + nested.remove(id); + nestedSelected.remove(id); + return; + } + if (closing.equals(open.get(id))) { + open.remove(id); + selected.remove(id); + // The carried boat item's lore shows cargo/fuel: keep it true + if (event.getPlayer() instanceof Player player) { + addon.getHoldService().active(id) + .ifPresent(hold -> addon.getBoatService().refreshItemLore(player, hold)); + } + } + } + + private void chime(Player player) { + player.playSound(player.getLocation(), Sound.ENTITY_ITEM_PICKUP, 0.5f, 1.2f); + } + + private void thud(Player player) { + player.playSound(player.getLocation(), Sound.BLOCK_ANVIL_LAND, 0.5f, 1.4f); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index d70bc9d..81be38f 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -1,41 +1,51 @@ package world.bentobox.tradewinds.travel; -import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.function.Predicate; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; import org.bukkit.Material; -import org.bukkit.block.ShulkerBox; -import org.bukkit.entity.ChestBoat; import org.bukkit.entity.Player; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.BlockStateMeta; -import org.bukkit.inventory.meta.BundleMeta; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.BoatHold; /** - * The hold: the ONLY container trade transacts against (spec principle 1). - * It is what the sailor carries as cargo - - *
    - *
  • trading bundles (pouches), up to max-bundles,
  • - *
  • cargo expanders (shulker boxes) - wherever they are carried,
  • - *
  • and the chest boat's own inventory while riding one.
  • - *
- * Loose items in pockets are invisible to the market: goods must be stowed in - * a pouch, an expander or the boat to be cargo. That keeps the fuel/cargo - * tension (principle 1) while leaving the hold always openable - a chest boat - * moored ashore, or sitting as an item in the pack, cannot be filled. + * The hold: the ONLY store trade transacts against (spec principle 1) - and it + * is the BOAT's, not the player's (ruled 2026-08-02). Every operation here + * resolves against the player's active boat record; with no boat there + * is no hold at all, and a two-slot raft carries two slots however rich its + * captain used to be. *

- * The cargo progression: pouch -> more pouches -> expanders -> chest boat. + * Contents are virtual: material → amount in the database, auto-consolidated, + * so no inventory trick can extract them. One-way rule: anything non-container + * can be added; cargo leaves only by being sold or destroyed. Fuel is exempt - + * it moves freely both ways, and fuel-valued cargo can be shifted into the + * fuel row. * * @author tastybento */ public class HoldService { + /** The most cargo slots any boat provides (the GUI renders this many). */ + public static final int MAX_CARGO_SLOTS = 21; + /** Dedicated fuel slots - fuel never competes with cargo. */ + public static final int FUEL_SLOTS = 7; + /** Cargo slots inside one installed expander (net +20: it occupies one). */ + public static final int EXPANDER_SLOTS = 21; + + /** + * Container items may never enter the hold - they would nest capacity. + * Boats are refused separately (a vessel is a key, not cargo). + */ + private static final Set CONTAINERS = Set.of(Material.BUNDLE, Material.CHEST, + Material.TRAPPED_CHEST, Material.BARREL, Material.ENDER_CHEST, Material.HOPPER, Material.DROPPER, + Material.DISPENSER, Material.FURNACE, Material.BLAST_FURNACE, Material.SMOKER, + Material.CHISELED_BOOKSHELF, Material.DECORATED_POT); + private final TradeWinds addon; public HoldService(TradeWinds addon) { @@ -43,341 +53,464 @@ public HoldService(TradeWinds addon) { } /** - * Everything sellable in the hold, aggregated by material, in encounter - * order. + * The player's active boat record - their hold. */ - public Map contents(Player player) { - return contents(player, stack -> true); + public Optional active(UUID playerId) { + return addon.getHoldManager().activeBoat(playerId); + } + + // ------------------------------------------------------------------ boat + + /** + * The one boat this player owns, or null for none. + */ + public Material boat(UUID playerId) { + return active(playerId).map(h -> Material.matchMaterial(h.getMaterial())).orElse(null); + } + + public Material boat(Player player) { + return boat(player.getUniqueId()); } /** - * Hold contents matching a stack filter (e.g. customs-stamped only). + * Cargo capacity in slots: the active boat's rank size, or 0 with none. */ - public Map contents(Player player, Predicate filter) { + public int capacitySlots(UUID playerId) { + return addon.getBoatRanks().slots(boat(playerId)); + } + + public int capacitySlots(Player player) { + return capacitySlots(player.getUniqueId()); + } + + // ----------------------------------------------------------------- cargo + + /** + * Cargo contents of a boat, materials in display order. + */ + public static Map contentsOf(BoatHold hold) { Map result = new LinkedHashMap<>(); - forEachHoldStack(player, stack -> { - if (filter.test(stack)) { - result.merge(stack.getType(), stack.getAmount(), Integer::sum); + hold.getContents().forEach((name, amount) -> { + Material material = Material.matchMaterial(name); + if (material != null && amount != null && amount > 0) { + result.put(material, amount); } }); return result; } + public Map contents(Player player) { + return contents(player.getUniqueId()); + } + + public Map contents(UUID playerId) { + return active(playerId).map(HoldService::contentsOf).orElseGet(LinkedHashMap::new); + } + /** - * How many of a material the hold contains. + * How many of one material the whole ship carries, expanders included. */ public int count(Player player, Material material) { - return contents(player).getOrDefault(material, 0); + return active(player.getUniqueId()).map(hold -> { + int total = hold.getContents().getOrDefault(material.name(), 0); + for (Map expander : hold.getExpanders()) { + total += expander.getOrDefault(material.name(), 0); + } + return total; + }).orElse(0); } /** - * How many of a material, counting only stacks passing the filter. + * Everything aboard for trade purposes: main cargo plus every expander. */ - public int count(Player player, Material material, Predicate filter) { - return contents(player, filter).getOrDefault(material, 0); + public Map tradeContents(Player player) { + Map result = contents(player.getUniqueId()); + active(player.getUniqueId()).ifPresent(hold -> hold.getExpanders().forEach(expander -> + expander.forEach((name, amount) -> { + Material material = Material.matchMaterial(name); + if (material != null && amount != null && amount > 0) { + result.merge(material, amount, Integer::sum); + } + }))); + return result; } /** - * Approximate free capacity of the hold in items, assuming 64-stacks: - * empty boat/expander slots count 64, partial stacks their headroom, - * bundles their remaining weight. + * Slots one amount of a material occupies, consolidated. */ - public int freeSpace(Player player) { - int free = 0; - if (player.getVehicle() instanceof ChestBoat boat) { - free += freeIn(boat.getInventory()); - } - for (ItemStack expander : expanders(player)) { - if (expander.getItemMeta() instanceof BlockStateMeta meta - && meta.getBlockState() instanceof ShulkerBox box) { - free += freeIn(box.getInventory()); + public static int slotsFor(Material material, int amount) { + int max = Math.max(1, material.getMaxStackSize()); + return (amount + max - 1) / max; + } + + /** + * Cargo slots in use: consolidated stacks plus one per installed expander. + */ + public int slotsUsed(UUID playerId) { + return active(playerId).map(HoldService::slotsUsedIn).orElse(0); + } + + static int slotsUsedIn(BoatHold hold) { + int used = hold.getExpanders().size(); + for (Map.Entry entry : hold.getContents().entrySet()) { + Material material = Material.matchMaterial(entry.getKey()); + if (material != null && entry.getValue() != null && entry.getValue() > 0) { + used += slotsFor(material, entry.getValue()); } } - for (ItemStack bundleItem : bundles(player)) { - if (bundleItem.getItemMeta() instanceof BundleMeta bundle) { - free += Math.max(0, 64 - bundle.getItems().stream().mapToInt(ItemStack::getAmount).sum()); - } + return used; + } + + public int slotsFree(UUID playerId) { + return Math.max(0, capacitySlots(playerId) - slotsUsed(playerId)); + } + + /** + * How many MORE of a material fit anywhere aboard: the main hold's free + * slots and headroom, plus every installed expander's. + */ + public int capacityFor(UUID playerId, Material material) { + if (refuses(material)) { + return 0; } - return free; + return active(playerId).map(hold -> { + int total = mainCapacityFor(hold, capacitySlots(playerId), material); + for (Map expander : hold.getExpanders()) { + total += storeCapacityFor(expander, EXPANDER_SLOTS, material); + } + return total; + }).orElse(0); } - private int freeIn(Inventory inventory) { - int free = 0; - for (ItemStack stack : inventory.getStorageContents()) { - if (stack == null || stack.getType().isAir()) { - free += 64; - } else if (stack.getMaxStackSize() >= 64 && !isExpander(stack)) { - free += stack.getMaxStackSize() - stack.getAmount(); + private static int mainCapacityFor(BoatHold hold, int capacitySlots, Material material) { + int max = Math.max(1, material.getMaxStackSize()); + int current = hold.getContents().getOrDefault(material.name(), 0); + int headroom = current == 0 ? 0 : slotsFor(material, current) * max - current; + int free = Math.max(0, capacitySlots - slotsUsedIn(hold)); + return free * max + headroom; + } + + /** + * Room in one bounded store (an expander's contents map). + */ + private static int storeCapacityFor(Map store, int slotBudget, Material material) { + int used = 0; + for (Map.Entry entry : store.entrySet()) { + Material m = Material.matchMaterial(entry.getKey()); + if (m != null && entry.getValue() != null && entry.getValue() > 0) { + used += slotsFor(m, entry.getValue()); } } - return free; + int free = Math.max(0, slotBudget - used); + int max = Math.max(1, material.getMaxStackSize()); + int current = store.getOrDefault(material.name(), 0); + int headroom = current == 0 ? 0 : slotsFor(material, current) * max - current; + return free * max + headroom; + } + + /** + * Whether the hold refuses this material outright: containers would nest + * capacity, and a vessel is a key, not cargo. + */ + public boolean refuses(Material material) { + return material == null || material.isAir() || CONTAINERS.contains(material) + || material.name().endsWith("SHULKER_BOX") || BoatRanks.isBoatItem(material); } /** - * Remove up to {@code amount} of a material from the hold (chest boat - * first, then expanders, then bundles). + * Add cargo to the player's boat, one-way, capacity- and + * container-checked: the main hold fills first, then the expanders. * - * @return how many were actually removed + * @return how many were actually added */ - public int remove(Player player, Material material, int amount) { - return remove(player, material, amount, stack -> true); + public int add(Player player, Material material, int amount) { + return active(player.getUniqueId()) + .map(hold -> addTo(hold, capacitySlots(player.getUniqueId()), material, amount)).orElse(0); } /** - * Remove up to {@code amount} of a material, taking only stacks that pass - * the filter (e.g. customs-stamped only). + * Add cargo to a specific boat - used when merging a salvaged hold. * - * @return how many were actually removed + * @return how many were actually added */ - public int remove(Player player, Material material, int amount, Predicate filter) { - int[] left = { amount }; - if (player.getVehicle() instanceof ChestBoat boat) { - Inventory inv = boat.getInventory(); - for (ItemStack stack : inv.getContents()) { - if (left[0] <= 0) { - break; - } - if (stack == null) { - continue; - } - if (stack.getType() == material && !isExpander(stack) && filter.test(stack)) { - int take = Math.min(left[0], stack.getAmount()); - stack.setAmount(stack.getAmount() - take); - if (stack.getAmount() <= 0) { - inv.remove(stack); - } - left[0] -= take; - } - } + public int addTo(BoatHold hold, int capacitySlots, Material material, int amount) { + if (refuses(material) || amount <= 0) { + return 0; } - for (ItemStack expander : expanders(player)) { - if (left[0] <= 0) { - break; - } - left[0] -= removeFromShulker(expander, material, left[0], filter); + int left = amount; + int main = Math.min(left, mainCapacityFor(hold, capacitySlots, material)); + if (main > 0) { + hold.getContents().merge(material.name(), main, Integer::sum); + left -= main; } - for (ItemStack bundleItem : bundles(player)) { - if (left[0] <= 0) { + for (Map expander : hold.getExpanders()) { + if (left <= 0) { break; } - left[0] -= removeFromBundle(bundleItem, material, left[0], filter); + int fit = Math.min(left, storeCapacityFor(expander, EXPANDER_SLOTS, material)); + if (fit > 0) { + expander.merge(material.name(), fit, Integer::sum); + left -= fit; + } } - return amount - left[0]; + int added = amount - left; + if (added > 0) { + addon.getHoldManager().save(hold); + } + return added; } /** - * Add items to the hold: chest boat first, then a cargo expander with - * room, then a bundle with room. + * Remove cargo - ONLY the market (sell) and the TNT slot (destroy) may + * call this; there is no path from here to a player inventory. * - * @return how many items were actually added (0 if no space) + * @return how many were actually removed + */ + public int remove(Player player, Material material, int amount) { + return active(player.getUniqueId()).map(hold -> removeFrom(hold, material, amount)).orElse(0); + } + + /** + * Remove cargo from a specific boat. */ - public int add(Player player, ItemStack items) { - int remaining = items.getAmount(); - // Expanders first: they are the purpose-built cargo space - for (ItemStack expander : expanders(player)) { - if (remaining <= 0) { + public int removeFrom(BoatHold hold, Material material, int amount) { + int left = amount; + left -= drain(hold.getContents(), material, left); + for (Map expander : hold.getExpanders()) { + if (left <= 0) { break; } - remaining -= addToShulker(expander, items, remaining); + left -= drain(expander, material, left); } - if (remaining > 0 && player.getVehicle() instanceof ChestBoat boat) { - ItemStack toAdd = items.clone(); - toAdd.setAmount(remaining); - Map leftover = boat.getInventory().addItem(toAdd); - remaining = leftover.values().stream().mapToInt(ItemStack::getAmount).sum(); + int taken = amount - left; + if (taken > 0) { + addon.getHoldManager().save(hold); } - // Bundles: only simple stackables, vanilla capacity 64 - for (ItemStack bundleItem : bundles(player)) { - if (remaining <= 0) { - break; - } - remaining -= addToBundle(bundleItem, items, remaining); + return taken; + } + + private static int drain(Map store, Material material, int amount) { + int current = store.getOrDefault(material.name(), 0); + int taken = Math.min(current, amount); + if (taken <= 0) { + return 0; } - return items.getAmount() - remaining; + if (taken == current) { + store.remove(material.name()); + } else { + store.put(material.name(), current - taken); + } + return taken; } /** - * Is this item a TradeWinds cargo expander (lore-renamed shulker box)? + * Move fuel-valued CARGO into the fuel row (e.g. coal bought at market) - + * a sanctioned third exit from the cargo slots, into the tank. + * + * @return how many moved */ - public boolean isExpander(ItemStack stack) { - // Any shulker box colour: expanders ship white to tell them apart from - // vanilla purple, but older purple ones stay valid - return stack != null && stack.getType().name().endsWith("SHULKER_BOX") && stack.hasItemMeta() - && stack.getItemMeta().getPersistentDataContainer().has( - org.bukkit.NamespacedKey.fromString("tradewinds:expander"), - org.bukkit.persistence.PersistentDataType.STRING); + public int moveCargoToFuel(Player player, Material material, int amount) { + if (addon.getFuelService().fuelValue(material) <= 0 || amount <= 0) { + return 0; + } + UUID playerId = player.getUniqueId(); + Optional hold = active(playerId); + if (hold.isEmpty()) { + return 0; + } + int fit = Math.min(amount, fuelCapacityFor(playerId, material)); + if (fit <= 0) { + return 0; + } + int taken = remove(player, material, fit); + if (taken <= 0) { + return 0; + } + hold.get().getFuel().merge(material.name(), taken, Integer::sum); + addon.getHoldManager().save(hold.get()); + return taken; + } + + // ------------------------------------------------------------- expanders + + public int expanderCount(UUID playerId) { + return active(playerId).map(h -> h.getExpanders().size()).orElse(0); } /** - * How many trading pouches the player carries, counting past the cap - the - * shipwright needs the true number to refuse selling more. + * Whether one more expander can be installed: only in the top boat, and + * only with a free cargo slot for it to occupy. */ - public int pouchCount(Player player) { - int count = 0; - for (ItemStack stack : player.getInventory().getContents()) { - if (stack != null && stack.getItemMeta() instanceof BundleMeta) { - count += stack.getAmount(); - } + public boolean canInstallExpander(UUID playerId) { + return boat(playerId) == Material.PALE_OAK_CHEST_BOAT && slotsFree(playerId) >= 1; + } + + public boolean installExpander(UUID playerId) { + if (!canInstallExpander(playerId)) { + return false; } - return count; + return active(playerId).map(hold -> { + hold.getExpanders().add(new LinkedHashMap<>()); + addon.getHoldManager().save(hold); + return true; + }).orElse(false); } /** - * The player's trading bundles, capped at the configured maximum - a - * fourth bundle is just a bag, not hold space. + * Whether the expanders' nested panels may be opened: only while the top + * boat carries them. Anywhere else they ride along inert, contents safe. */ - private List bundles(Player player) { - List result = new ArrayList<>(); - int max = addon.getSettings().getMaxBundles(); - for (ItemStack stack : player.getInventory().getContents()) { - if (result.size() >= max) { - break; - } - if (stack != null && stack.getItemMeta() instanceof BundleMeta) { - result.add(stack); + public boolean expandersOpenable(UUID playerId) { + return boat(playerId) == Material.PALE_OAK_CHEST_BOAT; + } + + public Map expanderContents(UUID playerId, int index) { + Map result = new LinkedHashMap<>(); + active(playerId).ifPresent(hold -> { + List> expanders = hold.getExpanders(); + if (index >= 0 && index < expanders.size()) { + expanders.get(index).forEach((name, amount) -> { + Material material = Material.matchMaterial(name); + if (material != null && amount != null && amount > 0) { + result.put(material, amount); + } + }); } - } + }); return result; } - /** - * Every cargo expander the sailor carries: in the pack, or stowed in the - * chest boat they are riding. - */ - public List expanders(Player player) { - List result = new ArrayList<>(); - for (ItemStack stack : player.getInventory().getContents()) { - if (isExpander(stack)) { - result.add(stack); - } + public int addToExpander(Player player, int index, Material material, int amount) { + if (refuses(material) || amount <= 0) { + return 0; } - if (player.getVehicle() instanceof ChestBoat boat) { - for (ItemStack stack : boat.getInventory().getContents()) { - if (isExpander(stack)) { - result.add(stack); - } + return active(player.getUniqueId()).map(hold -> { + List> expanders = hold.getExpanders(); + if (index < 0 || index >= expanders.size()) { + return 0; } - } - return result; + Map store = expanders.get(index); + int fit = Math.min(amount, storeCapacityFor(store, EXPANDER_SLOTS, material)); + if (fit <= 0) { + return 0; + } + store.merge(material.name(), fit, Integer::sum); + addon.getHoldManager().save(hold); + return fit; + }).orElse(0); } - private void forEachHoldStack(Player player, java.util.function.Consumer consumer) { - if (player.getVehicle() instanceof ChestBoat boat) { - for (ItemStack stack : boat.getInventory().getContents()) { - if (stack == null || stack.getType().isAir() || isExpander(stack)) { - continue; - } - consumer.accept(stack); + public int removeFromExpander(UUID playerId, int index, Material material, int amount) { + return active(playerId).map(hold -> { + List> expanders = hold.getExpanders(); + if (index < 0 || index >= expanders.size()) { + return 0; } - } - expanders(player).forEach(expander -> shulkerContents(expander).forEach(consumer)); - for (ItemStack bundleItem : bundles(player)) { - if (bundleItem.getItemMeta() instanceof BundleMeta bundle) { - bundle.getItems().stream().filter(i -> i != null && !i.getType().isAir()).forEach(consumer); + int taken = drain(expanders.get(index), material, amount); + if (taken > 0) { + addon.getHoldManager().save(hold); } - } + return taken; + }).orElse(0); } - private List shulkerContents(ItemStack expander) { - if (expander.getItemMeta() instanceof BlockStateMeta meta && meta.getBlockState() instanceof ShulkerBox box) { - List list = new ArrayList<>(); - for (ItemStack stack : box.getInventory().getContents()) { - if (stack != null && !stack.getType().isAir()) { - list.add(stack); - } + /** + * Destroy an EMPTY expander (the TNT refuses a loaded one). + */ + public boolean destroyExpander(UUID playerId, int index) { + return active(playerId).map(hold -> { + List> expanders = hold.getExpanders(); + if (index < 0 || index >= expanders.size() || !expanders.get(index).isEmpty()) { + return false; } - return list; - } - return List.of(); + expanders.remove(index); + addon.getHoldManager().save(hold); + return true; + }).orElse(false); } - private int removeFromShulker(ItemStack expander, Material material, int amount, Predicate filter) { - if (!(expander.getItemMeta() instanceof BlockStateMeta meta) - || !(meta.getBlockState() instanceof ShulkerBox box)) { - return 0; - } - int removed = 0; - for (ItemStack stack : box.getInventory().getContents()) { - if (removed >= amount) { - break; - } - if (stack != null && stack.getType() == material && filter.test(stack)) { - int take = Math.min(amount - removed, stack.getAmount()); - stack.setAmount(stack.getAmount() - take); - if (stack.getAmount() <= 0) { - box.getInventory().remove(stack); - } - removed += take; + // ------------------------------------------------------------------ fuel + + public Map fuelContents(UUID playerId) { + return active(playerId).map(HoldService::fuelOf).orElseGet(LinkedHashMap::new); + } + + public static Map fuelOf(BoatHold hold) { + Map result = new LinkedHashMap<>(); + hold.getFuel().forEach((name, amount) -> { + Material material = Material.matchMaterial(name); + if (material != null && amount != null && amount > 0) { + result.put(material, amount); } + }); + return result; + } + + public int fuelSlotsUsed(UUID playerId) { + int used = 0; + for (Map.Entry entry : fuelContents(playerId).entrySet()) { + used += slotsFor(entry.getKey(), entry.getValue()); } - if (removed > 0) { - meta.setBlockState(box); - expander.setItemMeta(meta); - } - return removed; + return used; } - private int addToShulker(ItemStack expander, ItemStack items, int amount) { - if (!(expander.getItemMeta() instanceof BlockStateMeta meta) - || !(meta.getBlockState() instanceof ShulkerBox box)) { + public int fuelCapacityFor(UUID playerId, Material material) { + if (addon.getFuelService().fuelValue(material) <= 0 || active(playerId).isEmpty()) { return 0; } - ItemStack toAdd = items.clone(); - toAdd.setAmount(amount); - Map leftover = box.getInventory().addItem(toAdd); - int added = amount - leftover.values().stream().mapToInt(ItemStack::getAmount).sum(); - if (added > 0) { - meta.setBlockState(box); - expander.setItemMeta(meta); - } - return added; + int max = Math.max(1, material.getMaxStackSize()); + int current = active(playerId).map(h -> h.getFuel().getOrDefault(material.name(), 0)).orElse(0); + int headroom = current == 0 ? 0 : slotsFor(material, current) * max - current; + int freeSlots = Math.max(0, FUEL_SLOTS - fuelSlotsUsed(playerId)); + return freeSlots * max + headroom; } - private int removeFromBundle(ItemStack bundleItem, Material material, int amount, Predicate filter) { - if (!(bundleItem.getItemMeta() instanceof BundleMeta bundle)) { + public int addFuel(Player player, Material material, int amount) { + UUID playerId = player.getUniqueId(); + int accepted = Math.min(amount, fuelCapacityFor(playerId, material)); + if (accepted <= 0) { return 0; } - int removed = 0; - List items = new ArrayList<>(bundle.getItems()); - List kept = new ArrayList<>(); - for (ItemStack stack : items) { - if (removed < amount && stack.getType() == material && filter.test(stack)) { - int take = Math.min(amount - removed, stack.getAmount()); - removed += take; - if (stack.getAmount() > take) { - ItemStack rest = stack.clone(); - rest.setAmount(stack.getAmount() - take); - kept.add(rest); - } - } else { - kept.add(stack); - } - } - if (removed > 0) { - bundle.setItems(kept); - bundleItem.setItemMeta(bundle); - } - return removed; + return active(playerId).map(hold -> { + hold.getFuel().merge(material.name(), accepted, Integer::sum); + addon.getHoldManager().save(hold); + return accepted; + }).orElse(0); } - private int addToBundle(ItemStack bundleItem, ItemStack items, int amount) { - if (!(bundleItem.getItemMeta() instanceof BundleMeta bundle) || items.getMaxStackSize() < 64) { - // Vanilla bundle weight rules for oddball items are not worth - // emulating - only plain stackables ride in bundles + /** + * Add fuel to a specific boat - used when merging a salvaged hold. + */ + public int addFuelTo(BoatHold hold, Material material, int amount) { + if (addon.getFuelService().fuelValue(material) <= 0 || amount <= 0) { return 0; } - int used = bundle.getItems().stream().mapToInt(ItemStack::getAmount).sum(); - int space = 64 - used; - int toAdd = Math.min(space, amount); - if (toAdd <= 0) { + int max = Math.max(1, material.getMaxStackSize()); + int used = 0; + for (Map.Entry entry : fuelOf(hold).entrySet()) { + used += slotsFor(entry.getKey(), entry.getValue()); + } + int current = hold.getFuel().getOrDefault(material.name(), 0); + int headroom = current == 0 ? 0 : slotsFor(material, current) * max - current; + int fit = Math.min(amount, Math.max(0, FUEL_SLOTS - used) * max + headroom); + if (fit <= 0) { return 0; } - List newItems = new ArrayList<>(bundle.getItems()); - ItemStack adding = items.clone(); - adding.setAmount(toAdd); - newItems.add(adding); - bundle.setItems(newItems); - bundleItem.setItemMeta(bundle); - return toAdd; + hold.getFuel().merge(material.name(), fit, Integer::sum); + addon.getHoldManager().save(hold); + return fit; + } + + /** + * Remove fuel - unlike cargo, fuel moves freely back out. + */ + public int removeFuel(UUID playerId, Material material, int amount) { + return active(playerId).map(hold -> { + int taken = drain(hold.getFuel(), material, amount); + if (taken > 0) { + addon.getHoldManager().save(hold); + } + return taken; + }).orElse(0); } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java index 3aabefc..33e15ac 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarterKit.java @@ -5,21 +5,18 @@ import org.bukkit.NamespacedKey; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; -import org.bukkit.persistence.PersistentDataType; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.dataobjects.TWPlayerData; /** - * The start of every trading career: a boat and one trading bundle (spec §4). - * Given once, on the player's first spawn. When the spawn point is water the - * boat is launched and the player seated in it; on land the boat goes in the - * inventory instead. Repeat visitors to spawn who carry a boat item get - * auto-launched too - nobody treads water at spawn. + * The start of every trading career: an Oak Boat (rank 2 of the ladder - the + * boat IS the hold) with the starter coal already in its fuel slots, and the + * starting balance. Given once, on the player's first spawn. When the spawn + * point is water the boat is launched and the player seated in it; on land + * the boat item goes in the pack instead. Repeat visitors to spawn who own a + * boat item get auto-launched too - nobody treads water at spawn. * * @author tastybento */ @@ -28,6 +25,9 @@ public class StarterKit { /** PDC key on boats recording the owning player's UUID (spec §4). */ public static final NamespacedKey BOAT_OWNER_KEY = NamespacedKey.fromString("tradewinds:owner"); + /** The rung a career starts on. */ + public static final Material STARTER_BOAT = Material.OAK_BOAT; + private final TradeWinds addon; public StarterKit(TradeWinds addon) { @@ -48,70 +48,57 @@ public void onSpawnArrival(Player player) { if (!data.isStarterKitGiven()) { data.setStarterKitGiven(true); addon.getPlayerDataManager().save(player.getUniqueId()); - player.getInventory().addItem(tradingBundle()); - if (onWater) { - launchBoat(player, spawn, Material.OAK_BOAT); - } else { - player.getInventory().addItem(new ItemStack(Material.OAK_BOAT)); - } - // Seed money for the first cargo - addon.getPlugin().getVault() - .ifPresent(vault -> vault.deposit(User.getInstance(player), - addon.getSettings().getStartingBalance())); - User.getInstance(player).sendMessage("tradewinds.starter-kit.given"); + give(player, spawn, onWater); return; } - // Repeat arrival: auto-launch a carried boat so nobody swims at spawn + // Repeat arrival: auto-launch the carried boat so nobody swims at spawn if (onWater && player.getVehicle() == null) { - Material carried = consumeBoatItem(player); - if (carried != null) { - launchBoat(player, spawn, carried); - } + addon.getHoldService().active(player.getUniqueId()).ifPresent(hold -> { + if (consumeBoatItem(player) != null) { + addon.getBoatService().launch(player, spawn, hold); + } + }); } } - private ItemStack tradingBundle() { - ItemStack pouch = addon.getMarketService().pouchItem(); - // A little coal in the pouch: it is hold cargo, so the first island hop - // can be a warp instead of a long row + /** + * The kit itself: boat on the hold record, coal in the fuel slots, coin + * in the purse. Also used by {@code /tw restart}. + */ + public void give(Player player, Location spawn, boolean onWater) { + var hold = addon.getBoatService().createFor(player, STARTER_BOAT); + // Coal straight into the fuel slots: the first island hop can be a + // warp instead of a long row int coal = addon.getSettings().getStarterCoal(); - if (coal > 0 && pouch.getItemMeta() instanceof org.bukkit.inventory.meta.BundleMeta bundleMeta) { - bundleMeta.setItems(java.util.List.of(new ItemStack(Material.COAL, coal))); - pouch.setItemMeta(bundleMeta); + if (coal > 0) { + addon.getHoldService().addFuel(player, Material.COAL, coal); } - return pouch; - } - - private void launchBoat(Player player, Location spawn, Material boatMaterial) { - // Boat item materials and boat entity types share names (OAK_BOAT...) - org.bukkit.entity.EntityType type; - try { - type = org.bukkit.entity.EntityType.valueOf(boatMaterial.name()); - } catch (IllegalArgumentException e) { - type = org.bukkit.entity.EntityType.OAK_BOAT; + if (onWater) { + addon.getBoatService().launch(player, spawn, hold); + } else { + addon.getBoatService().giveBoatItem(player, hold); } - org.bukkit.entity.Entity boat = spawn.getWorld().spawnEntity(spawn, type); - boat.getPersistentDataContainer().set(BOAT_OWNER_KEY, PersistentDataType.STRING, - player.getUniqueId().toString()); - boat.setPersistent(true); - boat.addPassenger(player); + addon.getPlugin().getVault() + .ifPresent(vault -> vault.deposit(User.getInstance(player), + addon.getSettings().getStartingBalance())); + User.getInstance(player).sendMessage("tradewinds.starter-kit.given"); } /** - * Remove one boat item from the inventory, plain boats preferred over - * chest boats. + * 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. + * * @return the removed boat material, or null if none carried */ private Material consumeBoatItem(Player player) { - Material found = null; - for (boolean allowChest : new boolean[] { false, true }) { - for (ItemStack stack : player.getInventory().getContents()) { - if (stack != null && stack.getType().name().endsWith("_BOAT") - && (allowChest || !stack.getType().name().contains("CHEST"))) { - found = stack.getType(); - stack.setAmount(stack.getAmount() - 1); - return found; - } + Material owned = addon.getHoldService().boat(player); + if (owned == null) { + return null; + } + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && stack.getType() == owned) { + stack.setAmount(stack.getAmount() - 1); + return owned; } } return null; diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index ac8865c..ca5b7ab 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -112,7 +112,8 @@ private ActionButton button(Player player, IslandSpec origin, Destination dest) dest.affordable() ? "tradewinds.ui.warp.destination" : "tradewinds.ui.warp.destination-poor", "[name]", spec.name(), "[fuel]", String.valueOf(dest.fuelCost())); Component tooltip = user(player).getTranslationAsComponent("tradewinds.ui.warp.destination-tooltip", - "[type]", spec.type().name(), "[band]", spec.band().getDisplayName(), "[distance]", + "[type]", spec.type().name(), "[tech]", String.valueOf(spec.techLevel()), + "[band]", spec.band().getDisplayName(), "[distance]", String.valueOf((int) Math.sqrt(spec.distanceSquared(origin.centerX(), origin.centerZ())))); DialogAction action = DialogAction.customClick( (response, audience) -> { @@ -185,18 +186,15 @@ private void standStillThen(Player player, Runnable jump, int fuelCost) { } /** - * Give back fuel value after an aborted warp, as charcoal into the hold - * (or at the player's feet if the hold is full). + * Give back fuel value after an aborted warp, as charcoal into the fuel + * slots (or at the player's feet if the fuel row is full). */ private void refund(Player player, int fuelUnits) { double charcoalValue = Math.max(1.0, addon.getSettings().getFuelValues().getOrDefault("CHARCOAL", 3.0)); - int amount = (int) Math.ceil(fuelUnits / charcoalValue); - ItemStack refund = new ItemStack(Material.CHARCOAL, Math.max(1, amount)); - int added = addon.getHoldService().add(player, refund); - if (added < refund.getAmount()) { - ItemStack rest = refund.clone(); - rest.setAmount(refund.getAmount() - added); - player.getWorld().dropItem(player.getLocation(), rest); + int amount = Math.max(1, (int) Math.ceil(fuelUnits / charcoalValue)); + int added = addon.getHoldService().addFuel(player, Material.CHARCOAL, amount); + if (added < amount) { + player.getWorld().dropItem(player.getLocation(), new ItemStack(Material.CHARCOAL, amount - added)); } } @@ -240,6 +238,21 @@ private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int f Location target = SeaArrival.openSeaOutward(addon.getGalaxyEngine(addon.getOverWorld().getSeed()), addon.getOverWorld(), to.centerX(), to.centerZ(), arrive[0], arrive[1], addon.getSettings().getSeaHeight()); + // Face the boat (and the sailor) at the destination's PIER: paddling + // straight ahead from a warp arrival is always the way in + world.bentobox.tradewinds.galaxy.DockPlan plan = addon + .getGalaxyEngine(addon.getOverWorld().getSeed()).dockPlan(to); + // The dock FLAG: the banner at the pier end, which is what a sailor + // actually steers for (IslandDecorator plants it 2 blocks short) + double pierX = to.centerX() + Math.cos(plan.bearing()) * (plan.dockEnd() - 2); + double pierZ = to.centerZ() + Math.sin(plan.bearing()) * (plan.dockEnd() - 2); + // The plain look-at yaw IS correct: the console proved it (computed + // 45.8 where the sailor's own F3 read 46.3 for the right heading). + // The quarter-turn "hull offset" of the first fix was chasing a + // different problem - the mount was resetting the facing, not the + // maths being sideways - and it only turned the boat the other way. + float lookYaw = yawToward(target.getX(), target.getZ(), pierX, pierZ); + target.setYaw(lookYaw); addon.log("Warp: " + player.getName() + " arriving at " + to.name() + " (" + target.getBlockX() + "," + target.getBlockY() + "," + target.getBlockZ() + ") - " @@ -252,10 +265,30 @@ private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int f } Util.teleportAsync(player, target).thenRun(() -> { if (vehicle instanceof Boat boat && boat.isValid()) { - boat.teleportAsync(target).thenRun(() -> Bukkit.getScheduler().runTask(addon.getPlugin(), - () -> boat.addPassenger(player))); + boat.teleportAsync(target).thenRun(() -> Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { + boat.addPassenger(player); + // Mounting drags the facing about (the sailor sees the + // boat pointing wherever they are looking), so set BOTH + // rotations after the re-seat rather than trusting the + // teleport to have stuck + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { + float yaw = target.getYaw(); + boat.setRotation(yaw, 0f); + player.setRotation(yaw, player.getLocation().getPitch()); + }, 2L); + })); } arrivalEffects(player, target); + // The re-seat raises the chart, but arrival blindness hides it - + // hence "sometimes I see the holograms, sometimes I do not" + // (playtest 2026-08-02). Raise it again once vision returns. + if (addon.getSettings().isChartOnBoarding()) { + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { + if (player.isOnline() && player.getWorld().equals(addon.getOverWorld())) { + addon.getChartHolograms().show(player); + } + }, (addon.getSettings().getWarpBlindnessSeconds() + 1) * 20L); + } user(player).sendMessage("tradewinds.warp.arrived", "[name]", to.name()); Bukkit.getPluginManager().callEvent(new TWWarpCompletedEvent(player, bearingFrom, to, fuelCost)); }); @@ -282,6 +315,32 @@ private void arrivalEffects(Player player, Location target) { target.getWorld().playSound(target, Sound.BLOCK_PORTAL_TRAVEL, 0.4f, 0.8f); } + /** + * The Minecraft yaw that looks from one point at another: 0 = south (+Z), + * -90 = east (+X). Pure, so the bearing is testable. + * + * @param fromX looker x + * @param fromZ looker z + * @param toX target x + * @param toZ target z + * @return yaw in degrees + */ + static float yawToward(double fromX, double fromZ, double toX, double toZ) { + return (float) Math.toDegrees(Math.atan2(-(toX - fromX), toZ - fromZ)); + } + + /** + * Wrap a yaw into (-180, 180] - the range Minecraft hands back, so a + * corrected value compares like an uncorrected one. + */ + static float normalise(float yaw) { + float wrapped = (yaw + 180f) % 360f; + if (wrapped < 0) { + wrapped += 360f; + } + return wrapped - 180f; + } + private world.bentobox.bentobox.api.user.User user(Player player) { return world.bentobox.bentobox.api.user.User.getInstance(player); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index dc81aaa..f0e26b8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -16,7 +16,7 @@ galaxy: # The galaxy seed. Every island position, type, security band, biome, name and # route cost derives deterministically from this one number - share it and another # server gets the same trading galaxy. 0 means: use the world seed. - seed: 0 + seed: 20260729 # Minimum separation between trading island centers in blocks. min-separation: 2500 # Radius in blocks around spawn inside which the starter-cluster density floor applies. @@ -49,6 +49,13 @@ galaxy: # Chance (0-1) that a wild islet is a mushroom island: mycelium, mooshrooms and # no hostile spawns. Rare enough to be worth the find. mushroom-islet-chance: 0.06 + # Chance (0-1) that a wild islet carries a small biome-appropriate vanilla + # structure at its heart: an igloo on the snowfields, fossils in the desert, + # a ruined portal in the jungle, an abandoned camp in the woods. Seeded and + # deterministic per islet. Mushroom islets and pale gardens always stay + # 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 # 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. @@ -72,14 +79,17 @@ travel: # Chance (0.0-1.0) that a warp fails and drops the player into the interstice. # Re-engaging the warp from the interstice to the original destination is always free. failure-chance: 0.05 - # Half-width of the offer ring around an island's visible border (the protection edge, - # where 'Now leaving...' appears): boated players crossing it get the warp dialog. - trigger-distance: 30 + # Half-width of the offer ring around an island's visible border (the protection + # edge - the RED particle curtain): boated players crossing it get the warp dialog. + # Small on purpose: you should have to touch the curtain. Keep a few blocks of + # width or ice-lane boats can cross the line between ticks. + trigger-distance: 5 # How far from the destination island's CENTRE a warp arrival lands. This wants - # to be at the island's visible border - the same place the warp dialog offers - # itself on the way out - so arriving is the mirror of leaving. 130 put sailors - # inside the island's own 160-block terrain footprint, on its underwater shelf. - arrival-distance: 400 + # to be just INSIDE the warp-offer ring (protection-range minus trigger-distance, + # with margin): landing on the ring itself reopened the warp dialog the sailor + # had just come through. 130 put sailors inside the island's own 160-block + # terrain footprint; 400 sat exactly on the offer ring. + arrival-distance: 320 # Seconds between automatic warp-dialog offers at the same island's border. prompt-cooldown-seconds: 30 # Maximum destinations listed in the warp dialog (nearest first). @@ -114,8 +124,8 @@ travel: # Warn when fuel is below this multiple of the cheapest route out. 1.0 warns only # when genuinely stuck; raise it to warn with something still in reserve. margin: 1.0 - # Fuel unit value per material. Fuel is consumed from the hold only: the chest - # boat's inventory and trading bundles - never loose pockets. + # Fuel unit value per material. Fuel lives in the hold's seven dedicated fuel + # slots - never loose pockets - and moves freely in and out of them. fuel-values: OAK_LOG: 1.0 SPRUCE_LOG: 1.0 @@ -187,7 +197,7 @@ crime: # Currency charged per point of reputation debt when paying a fine (/tw fine). # A fine can only ever take you back to Clean - money buys you out of Wanted, # not into virtue. - fine-per-point: 2.0 + fine-per-point: 20.0 # The safest band that will still trade with a FUGITIVE. Safer ports refuse them # outright: having burned your name, the only markets left are where the law is # thin. SAFE, POLICED, FRONTIER, LAWLESS or ANARCHIC. @@ -208,12 +218,12 @@ crime: # Money added to the offender's bounty per crime, paid once to whoever kills # them while they are a lawful target. bounties: - HURT_VILLAGER: 0.0 - KILL_VILLAGER: 100.0 - KILL_POLICE: 150.0 - KILL_INNOCENT: 500.0 - SMUGGLING: 200.0 - PASSENGER_TRADE: 150.0 + HURT_VILLAGER: 0 + KILL_VILLAGER: 1000 + KILL_POLICE: 1500 + KILL_INNOCENT: 5000 + SMUGGLING: 2000 + PASSENGER_TRADE: 1500 residents: # Distance from the plaza center beyond which a resident is teleported home. tether-radius: 24 @@ -243,7 +253,7 @@ hud: navigation-bossbar: true economy: # Money given to brand-new players with their starter kit. - starting-balance: 250.0 + starting-balance: 2500.0 # Coal tucked into the starter bundle - fuel for a first warp. 0 disables. starter-coal: 8 # Multiplier on what players PAY an island (buying) / what an island PAYS players (selling). @@ -256,61 +266,111 @@ economy: demand-factor: 1.4 # Extra demand multiplier per security band step - margins scale with danger. band-demand-bonus: 0.125 + # Price tilt per tech-level step away from 4 (the neutral level): high-tech + # islands sell FINISHED goods (metals, food) cheaper and pay more for RAW + # goods (ores, crops, wood, fish, stone) by this fraction per step; low-tech + # islands the inverse. The best routes are tech differentials. 0 disables. + tech-price-step: 0.03 # Stock units for a full price swing; drift clamps; hourly decay toward equilibrium. drift-scale: 500 drift-min: 0.7 drift-max: 1.3 stock-decay-per-hour: 50 # Cargo expanders: first price, doubling each purchase, hard cap per player. - expander-base-price: 5000.0 + expander-base-price: 50000.0 expander-cap: 4 - # Maximum trading pouches (bundles) that count as hold space. - max-bundles: 3 - # Price of a trading pouch. Deliberately cheap: with no pouch there is no hold, and - # with no hold a player can neither buy nor sell - so a pouch is the price of - # playing at all, not an upgrade. Flat by design: pricing by how many you carry is - # defeated by dropping one before buying; the max-bundles cap does the limiting. - pouch-price: 50.0 # The harbourmaster's charity: a sailor with no cargo space, no boat and no money # is given the bare minimum to work again. Charity goods cannot be sold. 0 disables. charity-cooldown-minutes: 15 - # Give customs-stamped goods an enchantment glint as well as their lore line. - stamp-glint: false # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. unstamped-sellables: - SUGAR # Base prices (Material -> price). Anything not listed is priced by deriving from # its crafting recipe (BlueBook logic, embedded); underivable = untradeable. base-prices: - WHEAT: 2.0 - CARROT: 1.5 - POTATO: 1.5 - SUGAR_CANE: 1.0 - SUGAR: 1.5 - BREAD: 3.0 - COOKED_BEEF: 4.0 - COD: 2.0 - SALMON: 3.0 - OAK_LOG: 1.5 - SPRUCE_LOG: 1.5 - DARK_OAK_LOG: 1.5 - STONE: 0.5 - COAL: 4.0 - CHARCOAL: 3.0 - RAW_IRON: 6.0 - RAW_COPPER: 3.0 - RAW_GOLD: 12.0 - IRON_INGOT: 9.0 - COPPER_INGOT: 4.0 - GOLD_INGOT: 18.0 - DIAMOND: 100.0 - EMERALD: 60.0 - AMETHYST_SHARD: 10.0 - CAKE: 20.0 - GOLDEN_APPLE: 150.0 - OAK_BOAT: 20.0 - OAK_CHEST_BOAT: 60.0 + WHEAT: 20 + CARROT: 15 + POTATO: 15 + SUGAR_CANE: 10 + SUGAR: 15 + BREAD: 30 + COOKED_BEEF: 40 + COD: 20 + SALMON: 30 + OAK_LOG: 15 + SPRUCE_LOG: 15 + DARK_OAK_LOG: 15 + STONE: 5 + COAL: 40 + CHARCOAL: 30 + RAW_IRON: 60 + RAW_COPPER: 30 + RAW_GOLD: 120 + IRON_INGOT: 90 + COPPER_INGOT: 40 + GOLD_INGOT: 180 + DIAMOND: 1000 + EMERALD: 600 + AMETHYST_SHARD: 100 + CAKE: 200 + GOLDEN_APPLE: 1500 + OAK_BOAT: 200 + OAK_CHEST_BOAT: 600 # (the full default table ships in code; add or override entries here) +border: + # Show island boundaries as particle curtains: the warp-offer ring at the + # protection edge (red - where the warp dialog fires) and the edge of island + # space (blue - where scans and island rules begin). Paint, not a wall. + particles-enabled: true + # Blocks from a boundary line within which its curtain renders. + view-distance: 32 + # Dust colors as 'r,g,b' (0-255). + warp-ring-color: 255,64,64 + edge-color: 64,128,255 + +boats: + # The boat ladder: cargo slots per boat material. A player has exactly ONE + # boat (or none) and this is the ONLY thing that sizes their hold. Order is + # by slots; shops list only boats bigger than yours, up to the island's tech + # (rank <= tech level x ranks-per-tech-level). Crafting ignores tech gates. + ranks: + BAMBOO_RAFT: 20 + OAK_BOAT: 30 + SPRUCE_BOAT: 40 + BAMBOO_CHEST_RAFT: 50 + BIRCH_BOAT: 60 + OAK_CHEST_BOAT: 70 + JUNGLE_BOAT: 80 + SPRUCE_CHEST_BOAT: 90 + ACACIA_BOAT: 100 + BIRCH_CHEST_BOAT: 110 + DARK_OAK_BOAT: 120 + JUNGLE_CHEST_BOAT: 130 + MANGROVE_BOAT: 140 + ACACIA_CHEST_BOAT: 150 + CHERRY_BOAT: 160 + DARK_OAK_CHEST_BOAT: 170 + PALE_OAK_BOAT: 180 + MANGROVE_CHEST_BOAT: 190 + CHERRY_CHEST_BOAT: 200 + PALE_OAK_CHEST_BOAT: 210 + # Boat shop price = this x slots squared. Bamboo Raft 1000, Pale Oak Chest 110250. + price-per-slot-squared: 250.0 + # Boat ranks sold at an island = its tech level x this (TL7 sells all 20). + ranks-per-tech-level: 3 + # How close your own boat must be for you to move another hull's cargo into + # it. Beyond this the option is not offered at all - cargo is not teleported + # across the ocean; you may still take the boat itself. + cargo-transfer-range: 200 + # The boat a boatless player is lent when they respawn (their own boat + # dropped where they died - rowing back out to reclaim it is the recovery + # trip). A boat-rank material name; NONE disables the loaner. + respawn-boat: BAMBOO_RAFT + # Minutes a dropped boat item (and the cargo riding in its record) survives + # before the sea claims it. Persisted in the database - restarts do not + # reset the clock. + dropped-boat-ttl-minutes: 30 + player: # Career restarts a destitute player may use (/tw restart). -1 unlimited, 0 none. max-restarts: 3 @@ -371,6 +431,10 @@ encounters: - COOKED_COD - TRIDENT illegal-trade: + # The contraband list: what customs care about, and what black markets pay + # the premium for. Only honored while illegal-trade.enabled is true. + contraband-materials: + - SUGAR # Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling. # Set false for family-friendly servers - removes the entire crime layer cleanly. enabled: true @@ -429,7 +493,7 @@ illegal-trade: chase-seconds: 120 # Fine per contraband item seized when caught, on top of losing the cargo and the # reputation. A player who cannot cover it pays what they have. - smuggling-fine-per-item: 5.0 + smuggling-fine-per-item: 50.0 # Tell players when a scan finds nothing - how a player learns the mechanic exists # before it costs them anything. announce-clean-scans: true diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index aa3470b..7a5b91f 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -20,7 +20,7 @@ tradewinds: warp: description: "open the warp dialog (in a boat, at an island)" chart: - description: "show your chart - holograms afloat, a list ashore" + description: "raise the chart compass - islands, dock, and your boats ('list' for text)" starchart: description: "receive a Star Chart map of your charted islands" trade: @@ -36,7 +36,7 @@ tradewinds: description: "list the nearest trading islands" none: "No trading islands found - is the galaxy density 0?" header: "Nearest trading islands (use /twadmin tpisland <#> to visit):" - entry: "[index]. [name] - [type], [band] at [x], [z] ([distance] blocks)" + entry: "[index]. [name] - [type] (Tech [tech]), [band] at [x], [z] ([distance] blocks)" tpisland: description: "teleport to a trading island from the islands list" parameters: "[number]" @@ -64,11 +64,11 @@ tradewinds: description: "re-apply security-band flags to all trading islands" done: "Re-applied band flags to [number] trading islands." spawn: - teleported: "You set sail on the open ocean." + teleported: "You come ashore at the spawn port. Your boat is your hold and your livelihood - keep it close." resumed: "You take up your voyage where you left it." already-at-sea: "You are already at sea. There is no commanding your way across it - row, or warp from an island's border." starter-kit: - given: "Your trading career begins: a boat, a trading pouch, and a little coal. Buy low, sell high." + given: "Your trading career begins with an Oak Boat: 3 cargo slots, coal in the fuel tank. The boat IS your hold - right-click it to open. Buy low, sell high." standing: upstanding: "Upstanding" clean: "Clean" @@ -114,14 +114,27 @@ tradewinds: low-actionbar: "[actionbar]LOW FUEL - [fuel]/[needed] to warp out. Buy fuel at the market." low-chat: "Low fuel at this port. You have [fuel] units; the cheapest warp from here needs [needed]. Buy at least [number] more from the market before you leave, or you will be rowing." hud: - navigation: "[name] | [band] | [standing] | Dock [distance]m" + navigation: "[name] | Tech [tech] | [band] | [standing] | Dock [distance]m" hologram: - island: "[name][type], [band][distance]m" + island: "[name][type] T[tech], [band][distance]m" + boat: "BOAT[distance]m" + old-boat: "OLD BOAT[distance]m" dock: "DOCK[name][distance]m" ui: + capture: + title: "The [material]" + body: "Take the [taking] and it becomes your ship - your [leaving], and everything in it, is left behind unowned for anyone to take. Find it again with /tw chart." + body-no-boat: "You have no ship. Take the [taking] and it is yours, cargo and all." + body-cargo: "It carries [cargo] items." + body-too-far: "Your [leaving] is too far off to load - cargo does not cross the ocean on its own." + confirm: "Take the [material]" + confirm-tooltip: "Your old boat and its cargo stay where they are" + transfer: "Take only the cargo" + transfer-tooltip: "Move what it carries into your own hold and leave the hull" + cancel: "Leave it" market: title: "[name] Market" - subtitle: "[type], [band]" + subtitle: "[type] (Tech [tech]), [band]" status: "Balance: $[balance] | Hold space: ~[space] items" sell: "Sell cargo" sell-tooltip: "Sell your hold's goods here" @@ -133,9 +146,10 @@ tradewinds: outfitter-fuel-tooltip: "You cannot warp anywhere without more fuel. Buy charcoal here." buy-fuel: "BUY FUEL HERE - Buy goods" buy-fuel-tooltip: "You cannot warp anywhere without more fuel. This island sells it." + no-ship-here: "No ship at this quay, so there is no cargo to trade - but the outfitter and the shipwright will still serve you." low-fuel: "LOW FUEL - not enough to warp anywhere from here." shipwright: "Shipwright" - shipwright-tooltip: "Boats, chest boats and cargo expanders" + shipwright-tooltip: "Bigger boats - an upgrade keeps your cargo and grows the hold" back: "< Back" back-tooltip: "Back to the market menu" close: "Close" @@ -147,7 +161,7 @@ tradewinds: outfitter-title: "[name] - Outfitter" outfitter-body: "Stores for the sailing life - food, fuel and gear" shipwright-title: "[name] - Shipwright" - shipwright-body: "Hulls and holds. Smart sailors craft their own boats from wild timber." + shipwright-body: "Upgrades only: your old boat is taken in and broken up, your cargo stays aboard. Smart sailors craft rare-wood boats instead of paying." sell-one: "[material] x1" sell-batch: "[material] x[amount]" sell-all: "All [amount] - $[price]" @@ -159,19 +173,21 @@ tradewinds: outfit-batch: "x[amount] - $[price]" outfit-tooltip: "$[price] each - carried in your pack, not the hold" hull: "[material] - $[price]" - hull-tooltip: "Delivered to your inventory - place it at the dock" + hull-tooltip: "[slots] cargo slots. Replaces your current boat; contents stay put." pouch: "Trading Pouch - $[price]" pouch-tooltip: "Holds 64 items ([owned]/[cap] carried). Cargo expanders carry 27 stacks each." expander: "Cargo Expander - $[price]" - expander-tooltip: "27 stacks of cargo space ([owned]/[cap] owned). Price doubles each time. Carried in your pack." + expander-tooltip: "+20 cargo slots, installed into a Pale Oak Chest Boat ([owned] installed). Price doubles each time." + no-hulls: "Nothing on the slipway beats your boat" + no-hulls-tooltip: "This port cannot build bigger than you sail. Seek higher tech." charity: "Harbourmaster's charity" - charity-tooltip: "Shipwrecked and penniless? Take a pouch, and a boat if you need one." + charity-tooltip: "Shipwrecked and penniless? Take a bamboo raft and start again." warp: title: "Warp - [name]" fuel: "Fuel aboard: [amount] units" destination: "[name] - [fuel] fuel" destination-poor: "[name] - [fuel] fuel" - destination-tooltip: "[type], [band], [distance] blocks" + destination-tooltip: "[type] (Tech [tech]), [band], [distance] blocks" interstice: title: "The Interstice" body: "Dark water, and something screaming above it. Your course is still paid for." @@ -180,6 +196,9 @@ tradewinds: stay: "Stay a while" stay-tooltip: "Close this. The offer comes round again, and the fuel is already spent." item: + boat-lore-cargo: "Cargo: [used]/[slots] slots" + boat-lore-fuel: "Fuel: [units] units" + boat-lore-open: "Right-click to open the hold" pouch: "Trading Pouch" pouch-lore: "Cargo space. Stow goods to sell them." expander: "Cargo Expander" @@ -194,16 +213,71 @@ tradewinds: starchart-given: "You unroll the Star Chart. Put it away and it is stowed - run the command again whenever you need it." empty: "Your chart is empty - row into an island's waters to chart it." header: "Your chart ([number] islands):" + your-boat: "Your [material]: [x], [z] ([distance] blocks)" + your-boat-elsewhere: "Your [material] is in another world ([world])." + your-boat-lost: "Your [material] has no recorded position - it has not been set down anywhere yet." + no-boat: "You have no ship." + old-boat: "Old [material]: [x], [z] ([distance] blocks) - unowned, anyone may take it" + old-boat-elsewhere: "Your old [material] is in another world ([world])." + old-boat-lost: "Your old [material] has no recorded position." fuel-aboard: "Fuel aboard: [amount] units. Reachable destinations are marked." - entry-reachable: "[fuel] fuel [name] - [type], [band], [distance] blocks" - entry-far: "[fuel] fuel [name] - [type], [band], [distance] blocks (not enough fuel)" + entry-reachable: "[fuel] fuel [name] - [type] T[tech], [band], [distance] blocks" + entry-far: "[fuel] fuel [name] - [type] T[tech], [band], [distance] blocks (not enough fuel)" restart: confirm: "Restart your career? Balance and kit reset; your chart is kept." none-left: "No restarts left (maximum [number])." done: "A fresh start. Restarts remaining: [number]." + boat: + label-owned: "[name]'s boat" + label-unowned: "UNOWNED" + protected: "That boat belongs to someone else, and this port protects it." + stolen: "Your boat was taken while you were away." + taken: "Someone has taken your boat!" + merged-empty: "You shift everything across and leave the empty [material] adrift." + merged-partial: "You shift across what fits - the rest stays in the [material], adrift at your feet." + adrift-raft: "Adrift with no boat - the harbourmaster's [material] is in your pack." + respawn-given: "The port lends you a [material] - your own boat is adrift where you fell." + 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." + upgraded-from-salvage: "The [material] is bigger than yours - you take it, cargo and all. Your old boat goes over the side in its place." + overflow-in-old-boat: "What would not fit went over the side in your old [material] - it floats where the wreck was." + salvage-emptied: "You strip the wreck: [amount] items into your hold. The empty hull is just a boat now." + salvage-partial: "You take [amount] items - your hold is full. The rest stays afloat for whoever comes next." + hold: + title: "Ship's Hold - [slots] slots" + border: " " + tnt: "Destroy cargo" + tnt-lore: "Select a cargo stack, then click here. Destroyed means GONE." + locked: "Locked" + locked-lore: "A bigger boat unlocks this slot." + fuel-slot: "Fuel slot" + fuel-slot-lore: "Wood, coal, lava. Fuel moves freely in and out." + selected: "[actionbar]Selected [amount] x [material] - click the TNT to destroy it." + select-first: "[actionbar]Select a cargo stack first, then click the TNT." + destroyed: "[actionbar][amount] x [material] destroyed and gone." + container-refused: "Containers cannot be stowed as cargo." + boat-refused: "A vessel is not cargo." + cargo-full: "The hold is full - and cargo only leaves by sale or destruction." + fuel-full: "The fuel slots are full." + moved-to-fuel: "[actionbar][amount] x [material] moved to the fuel slots." + expander-title: "Cargo Expander [number]" + expander: "Cargo Expander [number]" + expander-lore: "Click to open. Right-click (when empty) to select for the TNT." + expander-inert-lore: "Inert - expanders open only aboard a Pale Oak Chest Boat. Contents are safe." + expander-selected: "[actionbar]Expander [number] selected - click the TNT to break it up." + expander-not-empty: "Empty the expander first - the TNT refuses loaded ones." + expander-inert: "Expanders open only aboard a Pale Oak Chest Boat." + expander-destroyed: "[actionbar]Expander broken up." trade: not-at-market: "There is no market here - trade within an island's protection range." - not-stamped: "Traders only buy customs-stamped goods (or... certain other merchandise)." + nothing-of-that: "Nothing of that in your hold." + boat-bought: "[material] purchased for $[price] - [slots] cargo slots. Your old boat was broken up; your cargo is aboard." + boat-not-here: "Your ship is not at this port - bring it into the island's waters to trade." + refit-needs-ship: "The yard cannot refit a ship that is not here - bring it into the island's waters." + boat-not-sold-here: "This yard cannot sell you that boat." + boat-crafted: "You fit out the [material] - [slots] cargo slots. Your old boat is broken up; your cargo is aboard." + craft-smaller-refused: "That boat is no bigger than yours - you would be burning good wood to shrink your hold." contraband-refused: "The trader looks at what you are offering, then at you. 'Not here. Try a rougher port.'" barred: "The market closes as you approach. 'We know what you are. Take it somewhere lawless.'" nothing-to-sell: "Nothing in your hold that this island will pay for." @@ -211,12 +285,14 @@ tradewinds: sold: "Sold [amount] x [material] for $[price]." bought: "Bought [amount] x [material] for $[price]." cannot-afford: "You cannot afford that." - no-hold-space: "No room in your hold - stow cargo in a pouch, an expander or your chest boat." + no-hold-space: "No room in your hold - sell something, destroy something, or buy a bigger boat." needs-chest-boat: "Expanders are stowed in a chest boat - buy one from the shipwright first." - expander-bought: "Cargo expander purchased for $[price] - carried in your pack." + expander-bought: "Cargo expander installed for $[price]." + expander-not-sold-here: "Only the top-tech yards (Tech 7) fit expanders." + expander-needs-flagship: "Expanders install only into a Pale Oak Chest Boat with a free cargo slot." pouch-bought: "Trading pouch purchased for $[price]." pouch-cap: "You cannot carry any more trading pouches." - charity-given: "The harbourmaster hands you a pouch. 'Get back to work, sailor.'" + charity-given: "The harbourmaster waves at a bamboo raft. 'Get back to work, sailor.'" charity-not-needed: "The harbourmaster looks you over. 'You'll manage.'" charity-cooldown: "'I helped you already. Come back in [number] minutes.'" expander-cap: "Your boat cannot take any more expanders." diff --git a/src/test/java/world/bentobox/tradewinds/ResourceYamlTest.java b/src/test/java/world/bentobox/tradewinds/ResourceYamlTest.java new file mode 100644 index 0000000..db709c9 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/ResourceYamlTest.java @@ -0,0 +1,87 @@ +package world.bentobox.tradewinds; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; + +/** + * The shipped YAML must not carry duplicate keys. Bukkit's loader only WARNS + * ("duplicate keys found : description") and then silently keeps one of them, + * so a mis-indented block can quietly delete a message or a command + * description - twice now, from bulk locale edits. This catches it at build + * time instead of in the console. + * + * @author tastybento + */ +class ResourceYamlTest { + + /** Keys are "word chars, dashes, dots or a star", then a colon. */ + private static final Pattern KEY = Pattern.compile("^(\\s*)([A-Za-z0-9_.*\\-]+):(.*)$"); + + private List duplicatesIn(Path file) throws IOException { + List duplicates = new ArrayList<>(); + Map seen = new HashMap<>(); + // (indent, key) of the enclosing mappings + Deque indents = new ArrayDeque<>(); + Deque names = new ArrayDeque<>(); + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.isBlank() || line.stripLeading().startsWith("#") || line.stripLeading().startsWith("-")) { + continue; + } + Matcher m = KEY.matcher(line); + if (!m.matches()) { + continue; + } + int indent = m.group(1).length(); + String key = m.group(2); + while (!indents.isEmpty() && indents.peek()[0] >= indent) { + indents.pop(); + names.pop(); + } + List parts = new ArrayList<>(names.stream().toList()); + java.util.Collections.reverse(parts); + parts.add(key); + String full = String.join(".", parts); + Integer first = seen.put(full, i + 1); + if (first != null) { + duplicates.add(full + " (lines " + first + " and " + (i + 1) + ")"); + } + indents.push(new int[] { indent }); + names.push(key); + } + return duplicates; + } + + @Test + void testShippedYamlHasNoDuplicateKeys() throws IOException { + List files = new ArrayList<>(List.of(Path.of("src/main/resources/addon.yml"), + Path.of("src/main/resources/config.yml"))); + Path locales = Path.of("src/main/resources/locales"); + if (Files.isDirectory(locales)) { + try (var stream = Files.list(locales)) { + stream.filter(p -> p.toString().endsWith(".yml")).forEach(files::add); + } + } + List problems = new ArrayList<>(); + for (Path file : files) { + if (Files.exists(file)) { + duplicatesIn(file).forEach(d -> problems.add(file.getFileName() + ": " + d)); + } + } + assertTrue(problems.isEmpty(), "Duplicate YAML keys (Bukkit only warns, then drops one): " + problems); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/TestHolds.java b/src/test/java/world/bentobox/tradewinds/TestHolds.java new file mode 100644 index 0000000..364776a --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/TestHolds.java @@ -0,0 +1,91 @@ +package world.bentobox.tradewinds; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.Material; + +import world.bentobox.tradewinds.dataobjects.BoatHold; +import world.bentobox.tradewinds.dataobjects.HoldManager; +import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; +import world.bentobox.tradewinds.dataobjects.TestHoldManager; + +/** + * Boat plumbing for tests: a REAL {@link HoldManager} over an in-memory + * database, and a real player-data store behind it. The ownership rules (who + * loses a boat when one is taken, what becomes an OLD BOAT) are the part that + * has actually gone wrong in play, so tests must run the real thing rather + * than a stand-in that can drift from it. + * + * @author tastybento + */ +public final class TestHolds { + + private final Map players = new HashMap<>(); + private HoldManager manager; + + private TestHolds() { + // Built via install() + } + + /** + * Wire an in-memory hold manager and player store into a mocked addon. + * + * @param addon the mocked addon + * @return the helper, for handing out boats + */ + public static TestHolds install(TradeWinds addon) { + TestHolds holds = new TestHolds(); + PlayerDataManager pdm = mock(PlayerDataManager.class); + when(pdm.get(any(UUID.class))).thenAnswer(inv -> holds.players + .computeIfAbsent(inv.getArgument(0), id -> new TWPlayerData(id.toString()))); + when(pdm.findByOldBoat(any())).thenAnswer(inv -> { + String boatId = inv.getArgument(0); + return holds.players.entrySet().stream() + .filter(e -> boatId != null && boatId.equals(e.getValue().getOldBoat())) + .map(Map.Entry::getKey).findFirst(); + }); + when(addon.getPlayerDataManager()).thenReturn(pdm); + holds.manager = TestHoldManager.create(addon); + when(addon.getHoldManager()).thenReturn(holds.manager); + return holds; + } + + /** + * Give a player a boat of this material and make it active. + * + * @param playerId the player + * @param material the boat + * @return the record + */ + public BoatHold giveBoat(UUID playerId, Material material) { + BoatHold hold = manager.create(material, playerId); + players.computeIfAbsent(playerId, id -> new TWPlayerData(id.toString())) + .setActiveBoat(hold.getUniqueId()); + return hold; + } + + /** + * Take away whatever boat a player has, without abandoning it. + */ + public void clearBoat(UUID playerId) { + manager.clearActiveBoat(playerId); + } + + /** + * A loose boat nobody owns - salvage for the taking. + */ + public BoatHold unownedBoat(Material material) { + return manager.create(material, null); + } + + public HoldManager manager() { + return manager; + } +} diff --git a/src/test/java/world/bentobox/tradewinds/dataobjects/TestHoldManager.java b/src/test/java/world/bentobox/tradewinds/dataobjects/TestHoldManager.java new file mode 100644 index 0000000..b5dc462 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/dataobjects/TestHoldManager.java @@ -0,0 +1,50 @@ +package world.bentobox.tradewinds.dataobjects; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import world.bentobox.bentobox.database.Database; +import world.bentobox.tradewinds.TradeWinds; + +/** + * A real {@link HoldManager} over an in-memory database - so tests exercise + * the actual ownership rules (who loses a boat, what becomes an OLD BOAT) + * instead of a hand-written stand-in that can quietly disagree with them. + * + * @author tastybento + */ +public final class TestHoldManager { + + private TestHoldManager() { + // Static use only + } + + /** + * @param addon a mocked addon (its PlayerDataManager must already work) + * @return a real manager backed by a map + */ + @SuppressWarnings("unchecked") + public static HoldManager create(TradeWinds addon) { + Map stored = new HashMap<>(); + Database db = mock(Database.class); + when(db.objectExists(anyString())).thenAnswer(inv -> stored.containsKey(inv.getArgument(0))); + when(db.loadObject(anyString())).thenAnswer(inv -> stored.get(inv.getArgument(0))); + when(db.loadObjects()).thenAnswer(inv -> new java.util.ArrayList<>(stored.values())); + doAnswer(inv -> { + BoatHold hold = inv.getArgument(0); + stored.put(hold.getUniqueId(), hold); + return java.util.concurrent.CompletableFuture.completedFuture(true); + }).when(db).saveObjectAsync(any(BoatHold.class)); + doAnswer(inv -> { + stored.remove(inv.getArgument(0)); + return null; + }).when(db).deleteID(anyString()); + return new HoldManager(addon, db); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java b/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java index 1556a1c..fe33224 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/CharityTest.java @@ -1,27 +1,38 @@ package world.bentobox.tradewinds.economy; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.List; +import java.util.Optional; -import org.bukkit.inventory.ItemStack; +import org.bukkit.Material; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import world.bentobox.bentobox.hooks.VaultHook; import world.bentobox.tradewinds.CommonTestSetup; import world.bentobox.tradewinds.Settings; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.dataobjects.IslandDataManager; +import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; +import world.bentobox.tradewinds.dataobjects.BoatHold; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.IslandType; +import world.bentobox.tradewinds.galaxy.SecurityBand; +import world.bentobox.tradewinds.travel.BoatRanks; +import world.bentobox.tradewinds.travel.BoatService; import world.bentobox.tradewinds.travel.HoldService; /** - * The recovery rules: a sailor with no hold cannot earn anything (the market - * needs cargo space on both sides), so the first pouch must be cheap and the - * penniless must have a way back. + * The recovery rules under One Boat: a sailor with no boat has no hold and + * cannot earn, so destitution must have an exit - the harbourmaster's bamboo + * raft - and the starting balance must cover re-equipping. * * @author tastybento */ @@ -30,7 +41,9 @@ class CharityTest extends CommonTestSetup { private TradeWinds addon; private MarketService service; private HoldService hold; + private BoatService boats; private Settings settings; + private VaultHook vault; @Override @BeforeEach @@ -41,49 +54,82 @@ public void setUp() throws Exception { when(addon.getSettings()).thenReturn(settings); when(addon.getIslandDataManager()).thenReturn(mock(IslandDataManager.class)); when(addon.getPlugin()).thenReturn(plugin); - when(plugin.getVault()).thenReturn(java.util.Optional.empty()); + when(addon.getBoatRanks()).thenReturn(new BoatRanks(addon)); hold = mock(HoldService.class); when(addon.getHoldService()).thenReturn(hold); + boats = mock(BoatService.class); + when(addon.getBoatService()).thenReturn(boats); + PlayerDataManager pdm = mock(PlayerDataManager.class); + when(addon.getPlayerDataManager()).thenReturn(pdm); + when(pdm.get(any())).thenReturn(new TWPlayerData(uuid.toString())); + vault = mock(VaultHook.class); + when(plugin.getVault()).thenReturn(Optional.of(vault)); service = new MarketService(addon); } @Test - void testFirstPouchIsCheapEnoughToRecover() { - // The starting balance must comfortably cover a first pouch AND a boat, - // or a dead player is soft-locked out of the whole economy - assertTrue(settings.getPouchPrice() + 20 < settings.getStartingBalance(), - "A sailor must be able to re-equip from the starting balance"); + void testStartingBalanceCoversTheFirstRaft() { + // The smallest boat must be affordable from the starting balance, or a + // player who loses everything early is locked out of the whole economy + double raftPrice = settings.getBoatPricePerSlotSquared() * 2 * 2; // Bamboo raft: 2 slots + assertTrue(raftPrice < settings.getStartingBalance(), + "The bamboo raft must cost less than the starting balance"); } @Test - void testPouchPriceIsFlatSoItCannotBeGamed() { - // Pricing by carried pouches would be defeated by dropping one before - // buying and picking it up again; the cap does the limiting instead - for (int carried = 0; carried < 3; carried++) { - when(hold.pouchCount(mockPlayer)).thenReturn(carried); - assertEquals(settings.getPouchPrice(), service.pouchPrice(mockPlayer)); - } + void testDestituteMeansNoBoatAndNoMoney() { + when(vault.getBalance(any())).thenReturn(0.0); + // No boat, no money: destitute + when(hold.boat(mockPlayer)).thenReturn(null); + assertTrue(service.isDestitute(mockPlayer)); + // A boat - any boat - is never destitute + when(hold.boat(mockPlayer)).thenReturn(Material.BAMBOO_RAFT); + assertFalse(service.isDestitute(mockPlayer)); + // Money enough for a raft is not destitute either + when(hold.boat(mockPlayer)).thenReturn(null); + when(vault.getBalance(any())).thenReturn(10_000.0); + assertFalse(service.isDestitute(mockPlayer)); } @Test - void testDestituteMeansNoHoldAndNoMoney() { - when(hold.pouchCount(mockPlayer)).thenReturn(0); - when(hold.expanders(mockPlayer)).thenReturn(List.of()); - // Without an economy there is nothing to be destitute about - assertFalse(service.isDestitute(mockPlayer)); - // Carrying a pouch is never destitute - when(hold.pouchCount(mockPlayer)).thenReturn(1); - assertFalse(service.isDestitute(mockPlayer)); - // Nor is carrying an expander - when(hold.pouchCount(mockPlayer)).thenReturn(0); - when(hold.expanders(mockPlayer)).thenReturn(List.of(mock(ItemStack.class))); - assertFalse(service.isDestitute(mockPlayer)); + void testCharityGrantsTheBambooRaft() { + when(vault.getBalance(any())).thenReturn(0.0); + when(hold.boat(mockPlayer)).thenReturn(null); + assertTrue(service.claimCharity(mockPlayer)); + verify(boats).createFor(mockPlayer, Material.BAMBOO_RAFT); + // Charity never hands out money + verify(vault, org.mockito.Mockito.never()).deposit(any(), anyDouble()); + } + + @Test + void testBuyingWithNoBoatActuallyHandsOneOver() { + // Playtest 2026-08-02: a boatless player paid $100 for a Bamboo Raft + // and got nothing - the purchase only knew how to refit a record that + // already existed, so with no boat it took the money and did nothing. + when(hold.boat(mockPlayer)).thenReturn(null); + when(hold.active(any())).thenReturn(java.util.Optional.empty()); + when(vault.has(any(), org.mockito.ArgumentMatchers.anyDouble())).thenReturn(true); + var raft = new BoatRanks(addon).ladder().get(0); + BoatHold record = new BoatHold("test"); + record.setMaterial(Material.BAMBOO_RAFT.name()); + when(boats.createFor(mockPlayer, Material.BAMBOO_RAFT)).thenReturn(record); + + assertTrue(service.buyBoat(mockPlayer, spec(), raft)); + + verify(boats).createFor(mockPlayer, Material.BAMBOO_RAFT); + verify(boats).giveBoatItem(mockPlayer, record); + verify(vault).withdraw(any(), org.mockito.ArgumentMatchers.eq(1000.0)); + } + + private IslandSpec spec() { + return new IslandSpec(0, 0, 0, 0, IslandType.FISHING, SecurityBand.SAFE, "minecraft:plains", "Test", 3); } @Test - void testCharityGoodsCannotBeSold() { - // The pouch the harbourmaster gives is unstamped, so it cannot be sold - // back for money - there is nothing to farm - assertFalse(service.isStamped(service.pouchItem())); + void testCharityRefusedWhenNotDestitute() { + when(vault.getBalance(any())).thenReturn(0.0); + when(hold.boat(mockPlayer)).thenReturn(Material.OAK_BOAT); + assertFalse(service.claimCharity(mockPlayer)); + verify(boats, org.mockito.Mockito.never()).createFor(any(), any()); } } diff --git a/src/test/java/world/bentobox/tradewinds/economy/CustomsStampTest.java b/src/test/java/world/bentobox/tradewinds/economy/CustomsStampTest.java deleted file mode 100644 index d4cc643..0000000 --- a/src/test/java/world/bentobox/tradewinds/economy/CustomsStampTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package world.bentobox.tradewinds.economy; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import org.bukkit.Material; -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.TradeWinds; -import world.bentobox.tradewinds.dataobjects.IslandDataManager; - -/** - * The customs stamp: only trader-bought (stamped) goods are sellable - except - * the merchandise the customs office pretends not to know about. - * - * @author tastybento - */ -class CustomsStampTest extends CommonTestSetup { - - private TradeWinds addon; - private MarketService service; - private Settings settings; - - @Override - @BeforeEach - public void setUp() throws Exception { - super.setUp(); - addon = mock(TradeWinds.class); - settings = new Settings(); - when(addon.getSettings()).thenReturn(settings); - when(addon.getIslandDataManager()).thenReturn(mock(IslandDataManager.class)); - service = new MarketService(addon); - } - - private ItemStack stack(Material material, boolean stamped) { - ItemStack stack = mock(ItemStack.class); - when(stack.getType()).thenReturn(material); - when(stack.hasItemMeta()).thenReturn(true); - ItemMeta meta = mock(ItemMeta.class); - PersistentDataContainer pdc = mock(PersistentDataContainer.class); - when(pdc.has(MarketService.STAMP_KEY, PersistentDataType.STRING)).thenReturn(stamped); - when(meta.getPersistentDataContainer()).thenReturn(pdc); - when(stack.getItemMeta()).thenReturn(meta); - return stack; - } - - @Test - void testOnlyStampedGoodsAreSellable() { - assertTrue(service.sellableFilter().test(stack(Material.WHEAT, true))); - // Homegrown wheat is for eating, not selling - assertFalse(service.sellableFilter().test(stack(Material.WHEAT, false))); - assertFalse(service.sellableFilter().test(stack(Material.DIAMOND, false))); - } - - @Test - void testContrabandExceptionSellsUnstamped() { - // Homegrown sugar is the smallholder economy - sellable unstamped - assertTrue(service.sellableFilter().test(stack(Material.SUGAR, false))); - // ...but only while the illegal trade exists at all (family servers) - settings.setIllegalTradeEnabled(false); - assertFalse(service.sellableFilter().test(stack(Material.SUGAR, false))); - // Stamped sugar (bought legitimately) is always fine - assertTrue(service.sellableFilter().test(stack(Material.SUGAR, true))); - } -} diff --git a/src/test/java/world/bentobox/tradewinds/economy/PriceEngineTest.java b/src/test/java/world/bentobox/tradewinds/economy/PriceEngineTest.java index aa1c39e..b43d680 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/PriceEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/PriceEngineTest.java @@ -49,7 +49,7 @@ public void setUp() throws Exception { @Test void testConfiguredPrice() { // WHEAT is in the default base price table at 2.0 - assertEquals(2.0, engine.getPrice(new ItemStack(Material.WHEAT))); + assertEquals(20.0, engine.getPrice(new ItemStack(Material.WHEAT))); } @Test @@ -69,7 +69,7 @@ void testShapelessDerivation() { recipes.put(new ItemStack(Material.HAY_BLOCK), List.of(recipe)); // HAY_BLOCK is configured at 18.0 in defaults - remove to force derivation addonSettingsWithout("HAY_BLOCK"); - assertEquals(9 * 2.0, engine.getPrice(new ItemStack(Material.HAY_BLOCK))); + assertEquals(9 * 20.0, engine.getPrice(new ItemStack(Material.HAY_BLOCK))); } @Test @@ -79,7 +79,7 @@ void testOutputCountDividesCost() { when(recipe.getResult()).thenReturn(new ItemStack(Material.PAPER, 4)); when(recipe.getIngredientList()).thenReturn(List.of(new ItemStack(Material.WHEAT, 1))); recipes.put(new ItemStack(Material.PAPER), List.of(recipe)); - assertEquals(0.5, engine.getPrice(new ItemStack(Material.PAPER))); + assertEquals(5.0, engine.getPrice(new ItemStack(Material.PAPER))); } @Test @@ -89,7 +89,7 @@ void testSmeltingAddsFuelShare() { when(recipe.getResult()).thenReturn(new ItemStack(Material.COOKED_MUTTON, 1)); when(recipe.getInput()).thenReturn(new ItemStack(Material.MUTTON)); recipes.put(new ItemStack(Material.COOKED_MUTTON), List.of(recipe)); - assertEquals(2.0 + 4.0 / 8.0, engine.getPrice(new ItemStack(Material.COOKED_MUTTON))); + assertEquals(20.0 + 40.0 / 8.0, engine.getPrice(new ItemStack(Material.COOKED_MUTTON))); } @Test @@ -104,7 +104,7 @@ void testRecursiveDerivation() { when(outer.getIngredientList()).thenReturn(List.of(new ItemStack(Material.PAPER, 3))); recipes.put(new ItemStack(Material.BOOK), List.of(outer)); // BOOK = 3 x PAPER = 3 x (2 x 2.0) = 12.0 - assertEquals(12.0, engine.getPrice(new ItemStack(Material.BOOK))); + assertEquals(120.0, engine.getPrice(new ItemStack(Material.BOOK))); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java b/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java index 5038194..7eca3df 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java @@ -20,9 +20,10 @@ void testProfitableRouteExists() { double buyAt = model.playerBuysAt(10.0, model.economicFactor(true, false, 0, 0)); double sellAt = model.playerSellsAt(10.0, model.economicFactor(false, true, 0, 0)); assertTrue(sellAt > buyAt, "Produce->demand route must be profitable: buy " + buyAt + " sell " + sellAt); - // 10.0 * 0.6 * 1.15 = 6.90 ; 10.0 * 1.4 * 0.85 = 11.90 - assertEquals(6.90, buyAt); - assertEquals(11.90, sellAt); + // Whole coins, rounded AGAINST the player either way: + // buy ceil(10 * 0.6 * 1.15) = 7 ; sell floor(10 * 1.4 * 0.85) = 11 + assertEquals(7.0, buyAt); + assertEquals(11.0, sellAt); } @Test @@ -40,8 +41,9 @@ void testMarginsScaleWithDanger() { double safe = model.playerSellsAt(10.0, model.economicFactor(false, true, 0, 0)); double anarchic = model.playerSellsAt(10.0, model.economicFactor(false, true, 4, 0)); assertTrue(anarchic > safe); - // ANARCHIC pays 1.5x the SAFE demand price at defaults - assertEquals(safe * 1.5, anarchic, 0.01); + // ANARCHIC pays about 1.5x the SAFE demand price; whole-coin rounding + // moves it by less than a coin (11 -> 17 rather than 16.5) + assertEquals(safe * 1.5, anarchic, 1.0); } @Test @@ -61,4 +63,39 @@ void testExpanderPricingDoubles() { assertEquals(10000.0, PriceModel.expanderPrice(5000, 1)); assertEquals(40000.0, PriceModel.expanderPrice(5000, 3)); } + + @Test + void testEveryPriceIsAWholeCoin() { + // Adopted 2026-08-03: cents made the market charts ragged, and the + // rounding direction is what keeps the spread from closing + for (double base = 0.5; base < 2000; base += 3.7) { + for (boolean produces : new boolean[] { true, false }) { + double factor = model.economicFactor(produces, !produces, 2, 0); + double buy = model.playerBuysAt(base, factor); + double sell = model.playerSellsAt(base, factor); + assertEquals(buy, Math.floor(buy), "Buy price not whole: " + buy); + assertEquals(sell, Math.floor(sell), "Sell price not whole: " + sell); + assertTrue(buy >= 1, "Nothing is ever free: " + buy); + assertTrue(sell < buy, "Same-island round trip must lose: buy " + buy + " sell " + sell); + } + } + } + + @Test + void testTechTilt() { + PriceModel tech = new PriceModel(0.6, 1.4, 0.125, 1.15, 0.85, 500, 0.7, 1.3, 0.03); + // TL4 is neutral for everything + assertEquals(1.0, tech.techFactor(true, false, 4)); + assertEquals(1.0, tech.techFactor(false, true, 4)); + // High tech sells finished cheap (TL7: -9%) and buys raw dear (+9%) + assertEquals(0.91, tech.techFactor(true, false, 7), 1e-9); + assertEquals(1.09, tech.techFactor(false, true, 7), 1e-9); + // Low tech the inverse + assertEquals(1.09, tech.techFactor(true, false, 1), 1e-9); + assertEquals(0.91, tech.techFactor(false, true, 1), 1e-9); + // Neither raw nor finished: untouched at any tech + assertEquals(1.0, tech.techFactor(false, false, 7)); + // The tech-less convenience constructor is neutral everywhere + assertEquals(1.0, model.techFactor(true, false, 7)); + } } diff --git a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java index bd36e11..05efbda 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java @@ -79,13 +79,24 @@ void testOutfitterFitsTheDialog() { } @Test - void testShipwrightSellsHulls() { - // Boats purchasable everywhere - lose your boat, buy your way afloat - assertTrue(service.shipwrightCatalog().contains(Material.OAK_BOAT)); - assertTrue(service.shipwrightCatalog().contains(Material.OAK_CHEST_BOAT)); - // Priced (explicit base prices - above raw plank cost) - assertTrue(service.basePrice(Material.OAK_BOAT).orElse(0.0) >= 20.0); - assertTrue(service.basePrice(Material.OAK_CHEST_BOAT).orElse(0.0) > service.basePrice(Material.OAK_BOAT).orElse(0.0)); + void testBoatLadderShop() { + world.bentobox.tradewinds.travel.BoatRanks ranks = new world.bentobox.tradewinds.travel.BoatRanks(addon); + // The full ladder: 20 rungs, bamboo raft to pale oak chest boat + var ladder = ranks.ladder(); + assertTrue(ladder.size() == 20, "Ladder has " + ladder.size() + " rungs"); + assertTrue(ladder.get(0).material() == Material.BAMBOO_RAFT && ladder.get(0).slots() == 2); + assertTrue(ladder.get(19).material() == Material.PALE_OAK_CHEST_BOAT && ladder.get(19).slots() == 21); + // Quadratic prices, in whole coins: raft 1000, top boat 110250 + assertTrue(ranks.price(ladder.get(0)) == 1000.0); + assertTrue(ranks.price(ladder.get(19)) == 110250.0); + // Shops list bigger-only, tech-gated: TL1 sells ranks 1-3 only + var tl1 = ranks.shopListing(null, 1); + assertTrue(tl1.size() == 3 && tl1.get(2).material() == Material.SPRUCE_BOAT); + // With a spruce boat at TL1 there is nothing left to buy + assertTrue(ranks.shopListing(Material.SPRUCE_BOAT, 1).isEmpty()); + // TL7 sells everything bigger than yours + assertTrue(ranks.shopListing(Material.CHERRY_CHEST_BOAT, 7).size() == 1); + assertTrue(ranks.shopListing(Material.PALE_OAK_CHEST_BOAT, 7).isEmpty(), "Top of the tree"); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java index dd33782..07f24e1 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/GalaxyEngineTest.java @@ -286,6 +286,164 @@ void testWildIslets() { } } + @Test + void testIsletBiomesMatchTheirSea() { + // An islet's land suits the water it stands in: its biome comes from + // the band for the sea temperature at its center - no snowfields in + // warm water, no jungles in the frozen sea + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + java.util.Set seen = new java.util.HashSet<>(); + for (int cx = -40; cx <= 40; cx++) { + for (int cz = -40; cz <= 40; cz++) { + Optional islet = engine.wildIsletInCell(cx, cz); + if (islet.isEmpty() || islet.get().isMushroom()) { + continue; + } + Islet i = islet.get(); + List band = GalaxyEngine.WILD_BIOMES + .get(engine.oceanTemperatureIndex(i.centerX(), i.centerZ())); + assertTrue(band.contains(i.biomeKey()), + i.biomeKey() + " does not belong in the sea at " + i.centerX() + "," + i.centerZ()); + seen.add(i.biomeKey()); + } + } + // A scan across several temperature regions turns up real variety, + // including the rare finds this feature exists for + assertTrue(seen.size() > 20, "Only " + seen.size() + " islet biomes in a 72km scan: " + seen); + assertTrue(seen.contains("minecraft:cherry_grove"), "No cherry grove islet found"); + assertTrue(seen.contains("minecraft:pale_garden"), "No pale garden islet found"); + } + + @Test + void testTechLevels() { + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.5)); + List all = islands(engine, 12); + assertFalse(all.isEmpty()); + // In range, deterministic across engines + GalaxyEngine again = new GalaxyEngine(config(SEED, 0.5)); + for (IslandSpec spec : all) { + assertTrue(spec.techLevel() >= 1 && spec.techLevel() <= GalaxyEngine.MAX_TECH_LEVEL, + spec.name() + " has tech " + spec.techLevel()); + assertEquals(spec.techLevel(), + again.islandInCell(spec.cellX(), spec.cellZ()).orElseThrow().techLevel()); + } + // Type correlation: industry averages higher than agriculture + double industrial = all.stream().filter(s -> s.type() == IslandType.INDUSTRIAL) + .mapToInt(IslandSpec::techLevel).average().orElse(0); + double agricultural = all.stream().filter(s -> s.type() == IslandType.AGRICULTURAL) + .mapToInt(IslandSpec::techLevel).average().orElse(99); + assertTrue(industrial > agricultural, + "INDUSTRIAL avg " + industrial + " should beat AGRICULTURAL avg " + agricultural); + // The wobble means advanced farms exist somewhere + assertTrue(all.stream().anyMatch(s -> s.type() == IslandType.AGRICULTURAL && s.techLevel() >= 4), + "No Advanced Agricultural island in a 12-cell scan"); + } + + @Test + void testStarterClusterGuaranteesATechFloor() { + // Whatever the seed rolls, a fresh sailor can always reach a TL3+ shop + // without leaving the pre-charted starter cluster + for (long seed = 900; seed < 940; seed++) { + GalaxyEngine engine = new GalaxyEngine(config(seed, 0.5)); + int cells = 2; // starter cells are the 5 nearest the origin + int best = 0; + for (int cx = -cells; cx <= cells; cx++) { + for (int cz = -cells; cz <= cells; cz++) { + best = Math.max(best, engine.islandInCell(cx, cz).map(IslandSpec::techLevel).orElse(0)); + } + } + assertTrue(best >= 3, "Seed " + seed + ": best starter tech is " + best); + } + } + + @Test + void testSurfacesFollowTheBiome() { + // Ground truth, literally: a desert stands on sand, a mangrove swamp + // on mud, the badlands on red sand - not everything on a lawn. This + // expectation table is a deliberate duplicate of the engine's: a + // mapping change must be made twice, consciously. + java.util.Map expected = new java.util.HashMap<>(); + // A sandbar's heart can BE its shore, so beaches appear here too + expected.put("minecraft:beach", SurfaceKind.SAND); + expected.put("minecraft:snowy_beach", SurfaceKind.SAND); + expected.put("minecraft:desert", SurfaceKind.SAND); + expected.put("minecraft:badlands", SurfaceKind.RED_SAND); + expected.put("minecraft:eroded_badlands", SurfaceKind.RED_SAND); + expected.put("minecraft:wooded_badlands", SurfaceKind.RED_SAND); + expected.put("minecraft:old_growth_pine_taiga", SurfaceKind.PODZOL); + expected.put("minecraft:old_growth_spruce_taiga", SurfaceKind.PODZOL); + expected.put("minecraft:stony_shore", SurfaceKind.STONE); + expected.put("minecraft:stony_peaks", SurfaceKind.STONE); + expected.put("minecraft:jagged_peaks", SurfaceKind.STONE); + expected.put("minecraft:windswept_gravelly_hills", SurfaceKind.GRAVEL); + expected.put("minecraft:mangrove_swamp", SurfaceKind.MUD); + expected.put("minecraft:grove", SurfaceKind.SNOW); + expected.put("minecraft:snowy_slopes", SurfaceKind.SNOW); + expected.put("minecraft:frozen_peaks", SurfaceKind.SNOW); + expected.put("minecraft:ice_spikes", SurfaceKind.SNOW); + expected.put(GalaxyEngine.MUSHROOM_BIOME, SurfaceKind.MYCELIUM); + + GalaxyEngine engine = new GalaxyEngine(config(SEED, 0.0)); + java.util.Set seen = new java.util.HashSet<>(); + for (int cx = -40; cx <= 40; cx++) { + for (int cz = -40; cz <= 40; cz++) { + Optional islet = engine.wildIsletInCell(cx, cz); + if (islet.isEmpty()) { + continue; + } + Islet i = islet.get(); + // The islet's heart is inland, so the biome (not the beach + // fringe) governs it + String biome = engine.biomeKeyAt(i.centerX(), i.centerZ()).orElseThrow(); + SurfaceKind kind = engine.surfaceKindAt(i.centerX(), i.centerZ()); + assertEquals(expected.getOrDefault(biome, SurfaceKind.GRASS), kind, + "Wrong ground under " + biome + " at " + i.centerX() + "," + i.centerZ()); + seen.add(kind); + } + } + assertTrue(seen.size() >= 5, "A 72km scan should stand on many grounds, saw only " + seen); + + // Trading islands follow the same rule: an INDUSTRIAL island's desert + // or badlands is sand or red sand at its heart + GalaxyEngine dense = new GalaxyEngine(config(SEED, 1.0)); + boolean checkedIsland = false; + for (IslandSpec spec : islands(dense, 15)) { + if (spec.type() == IslandType.INDUSTRIAL) { + SurfaceKind kind = dense.surfaceKindAt(spec.centerX(), spec.centerZ()); + assertTrue(kind == SurfaceKind.SAND || kind == SurfaceKind.RED_SAND, + spec.biomeKey() + " island stands on " + kind); + checkedIsland = true; + } + } + assertTrue(checkedIsland, "No INDUSTRIAL island in range to check"); + } + + @Test + void testEveryOverworldLandBiomeCanExist() { + // The whole point: between trading islands and wild islets, every + // generatable overworld land biome in the 26.2 registry has somewhere + // it can appear (cave, river, ocean, Nether and End biomes excepted) + java.util.Set reachable = new java.util.HashSet<>(GalaxyEngine.isletBiomes()); + for (IslandType type : IslandType.values()) { + reachable.addAll(type.getBiomeKeys()); + } + List landBiomes = List.of("minecraft:badlands", "minecraft:bamboo_jungle", "minecraft:beach", + "minecraft:birch_forest", "minecraft:cherry_grove", "minecraft:dark_forest", "minecraft:desert", + "minecraft:eroded_badlands", "minecraft:flower_forest", "minecraft:forest", + "minecraft:frozen_peaks", "minecraft:grove", "minecraft:ice_spikes", "minecraft:jagged_peaks", + "minecraft:jungle", "minecraft:mangrove_swamp", "minecraft:meadow", "minecraft:mushroom_fields", + "minecraft:old_growth_birch_forest", "minecraft:old_growth_pine_taiga", + "minecraft:old_growth_spruce_taiga", "minecraft:pale_garden", "minecraft:plains", + "minecraft:savanna", "minecraft:savanna_plateau", "minecraft:snowy_beach", + "minecraft:snowy_plains", "minecraft:snowy_slopes", "minecraft:snowy_taiga", + "minecraft:sparse_jungle", "minecraft:stony_peaks", "minecraft:stony_shore", + "minecraft:sunflower_plains", "minecraft:swamp", "minecraft:taiga", + "minecraft:windswept_forest", "minecraft:windswept_gravelly_hills", "minecraft:windswept_hills", + "minecraft:windswept_savanna", "minecraft:wooded_badlands"); + List missing = landBiomes.stream().filter(b -> !reachable.contains(b)).toList(); + assertTrue(missing.isEmpty(), "Land biomes with nowhere to exist: " + missing); + } + @Test void testIsletsVaryInSize() { // Sandbars and proper little islands, not one stamped shape diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/IsletStructuresTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/IsletStructuresTest.java new file mode 100644 index 0000000..fdf45ee --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/galaxy/IsletStructuresTest.java @@ -0,0 +1,126 @@ +package world.bentobox.tradewinds.galaxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +/** + * The islet structure lottery - pure, seeded, and biome-complete. + * + * @author tastybento + */ +class IsletStructuresTest { + + private static final long SEED = 424242L; + + /** Biomes that deliberately never decorate: the biome is the find. */ + private static final Set PRISTINE = Set.of("minecraft:pale_garden", GalaxyEngine.MUSHROOM_BIOME); + + private Islet islet(int x, int z, String biome) { + return new Islet(x, z, 60, biome); + } + + @Test + void testEveryIsletBiomeHasATableEntry() { + // A new islet biome with no entry would silently never decorate - + // fail loudly instead. (Beaches are fringes, not whole-islet biomes.) + for (String biome : GalaxyEngine.isletBiomes()) { + if (biome.endsWith("beach")) { + continue; + } + assertTrue(IsletStructures.coveredBiomes().contains(biome), + biome + " has no structure table entry (empty is fine, missing is not)"); + } + } + + @Test + void testChanceZeroPlacesNothing() { + for (int i = 0; i < 50; i++) { + assertTrue(IsletStructures.pick(SEED, islet(i * 977, -i * 1231, "minecraft:desert"), 0.0).isEmpty()); + } + } + + @Test + void testChanceOnePlacesOnEveryEligibleIslet() { + for (String biome : GalaxyEngine.isletBiomes()) { + if (biome.endsWith("beach")) { + continue; + } + Optional pick = IsletStructures.pick(SEED, islet(1000, 2000, biome), 1.0); + if (PRISTINE.contains(biome)) { + assertTrue(pick.isEmpty(), biome + " must stay pristine"); + } else { + assertTrue(pick.isPresent(), biome + " should place at chance 1.0"); + assertTrue(pick.get().template().matches("[a-z0-9_/]+"), + "Suspicious template key: " + pick.get().template()); + assertTrue(pick.get().sink() >= 0 && pick.get().sink() <= 4, + "Sink out of range for " + pick.get().template()); + } + } + } + + @Test + void testEveryPlacementDeclaresItsJigsawFill() { + // Several vanilla templates carry jigsaw connectors that vanilla's + // own assembler would have swapped for a final_state. Placed raw they + // stand there glowing (playtest 2026-08-02), so every placement must + // name a real material to put in their place - and the ruined portals + // that sit on netherrack must say so, or the portal loses its footing. + for (String biome : GalaxyEngine.isletBiomes()) { + if (biome.endsWith("beach") || PRISTINE.contains(biome)) { + continue; + } + for (int i = 0; i < 60; i++) { + IsletStructures.pick(SEED + i, islet(i * 811, i * 47, biome), 1.0).ifPresent(p -> { + assertTrue(p.jigsawFill() != null && !p.jigsawFill().isBlank(), + p.template() + " does not say what its jigsaws become"); + assertTrue(org.bukkit.Material.matchMaterial(p.jigsawFill()) != null, + p.template() + " names an unknown material: " + p.jigsawFill()); + if (p.template().matches("ruined_portal/portal_[1245]")) { + assertEquals("NETHERRACK", p.jigsawFill(), + p.template() + " stands on netherrack in its own NBT"); + } + }); + } + } + } + + @Test + void testDeterministicAndVaried() { + Set seen = new HashSet<>(); + int placed = 0; + for (int i = 0; i < 400; i++) { + Islet spot = islet(i * 913, i * -1717, List.of("minecraft:desert", "minecraft:forest", + "minecraft:snowy_plains", "minecraft:jungle", "minecraft:savanna").get(i % 5)); + Optional a = IsletStructures.pick(SEED, spot, 0.25); + assertEquals(a, IsletStructures.pick(SEED, spot, 0.25), "Same seed+islet must pick the same"); + if (a.isPresent()) { + placed++; + seen.add(a.get().template()); + } + } + // ~25% place, drawn from a real menu, not one stamp + assertTrue(placed > 50 && placed < 150, "Placement rate off: " + placed + "/400"); + assertTrue(seen.size() > 8, "Only " + seen.size() + " distinct templates: " + seen); + } + + @Test + void testBiomeAppropriateness() { + // The headline pairings hold: igloos on snow, bones in the sand + for (int i = 0; i < 200; i++) { + IsletStructures.pick(SEED, islet(i * 331, i * 733, "minecraft:snowy_plains"), 1.0) + .ifPresent(p -> assertTrue(p.template().startsWith("igloo/"), + "Snowfields place igloos, got " + p.template())); + IsletStructures.pick(SEED, islet(i * 331, i * 733, "minecraft:desert"), 1.0) + .ifPresent(p -> assertTrue( + p.template().startsWith("fossil/") || p.template().startsWith("ruined_portal/"), + "Deserts place bones or portals, got " + p.template())); + } + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 961eeb8..408aa9d 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -244,18 +244,21 @@ void testTerrainLiftMakesIslands() { ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, spec.centerX() >> 4, spec.centerZ() >> 4); - boolean grassAboveSea = false; - for (int x = 0; x < 16 && !grassAboveSea; x++) { - for (int z = 0; z < 16 && !grassAboveSea; z++) { + // Land, not necessarily lawn: the surface follows the island's biome + // (sand under a desert port, mud under a mangrove one) + boolean landAboveSea = false; + for (int x = 0; x < 16 && !landAboveSea; x++) { + for (int z = 0; z < 16 && !landAboveSea; z++) { for (int y = settings.getSeaHeight() + 1; y < settings.getSeaHeight() + 45; y++) { - if (r.get(x, y, z) == Material.GRASS_BLOCK) { - grassAboveSea = true; + Material m = r.get(x, y, z); + if (m != Material.AIR && m != Material.WATER) { + landAboveSea = true; break; } } } } - assertTrue(grassAboveSea, "The island mask should lift grassy land above sea level"); + assertTrue(landAboveSea, "The island mask should lift land above sea level"); // And the interstice ignores the galaxy entirely RecordingChunkData nether = generate(gen, Environment.NETHER, SEED, spec.centerX() >> 4, spec.centerZ() >> 4); @@ -301,9 +304,12 @@ void testSpawnIslandRisesAtOrigin() { .thenReturn(new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70))); ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); RecordingChunkData r = generate(gen, Environment.NORMAL, SEED, 0, 0); + // Any solid ground counts: the spawn island's surface follows its + // biome, which need not be grass boolean landAboveSea = false; for (int y = settings.getSeaHeight() + 1; y < settings.getSeaHeight() + 50 && !landAboveSea; y++) { - landAboveSea = r.get(0, y, 0) == Material.GRASS_BLOCK; + Material m = r.get(0, y, 0); + landAboveSea = m != Material.AIR && m != Material.WATER; } assertTrue(landAboveSea, "The spawn island should rise above the sea at the origin"); } diff --git a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java index 7793a46..55af0a5 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/IslandDecoratorTest.java @@ -116,6 +116,14 @@ void testPlazaChunkIsDecorated() { // Residents assertFalse(villagers.isEmpty(), "No villagers spawned"); assertEquals(IslandDecorator.golemCount(spec.band()), golems.size()); + // The galley: every plaza offers a public workbench and cooking hearth, + // so a sailor can craft and cook their catch (both visitor-usable) + assertTrue(placed.contains(Material.CRAFTING_TABLE), "No public workbench placed"); + assertTrue(placed.contains(Material.CAMPFIRE), "No cooking hearth placed"); + assertTrue(placed.contains(Material.COBBLESTONE), "The campfire must stand on a hearth block"); + // And the tech level furnishes the amenity row beyond it + IslandPalette.amenities(spec.techLevel()) + .forEach(a -> assertTrue(placed.contains(a), "Missing tech amenity " + a)); // Workstations and the type landmark are present assertTrue(placed.contains(IslandPalette.workstations(spec.type()).get(0)), "No workstation placed"); assertTrue(placed.stream().anyMatch(LANDMARK_SIGNATURES.get(spec.type())::contains), @@ -129,6 +137,23 @@ void testPlazaChunkIsDecorated() { villagers.forEach(v -> verify(v).setVillagerExperience(1)); } + @Test + void testAmenitiesGrowWithTech() { + // Low tech gets only the galley; the ladder adds smelting, smithing, + // brewing, an anvil, and finally enchanting - cumulative + assertTrue(IslandPalette.amenities(1).isEmpty()); + assertTrue(IslandPalette.amenities(2).isEmpty()); + java.util.List t3 = IslandPalette.amenities(3); + assertTrue(t3.contains(Material.FURNACE) && t3.contains(Material.STONECUTTER)); + assertFalse(t3.contains(Material.BREWING_STAND)); + java.util.List t5 = IslandPalette.amenities(5); + assertTrue(t5.contains(Material.BREWING_STAND) && t5.contains(Material.CAULDRON)); + assertFalse(t5.contains(Material.ENCHANTING_TABLE)); + java.util.List t7 = IslandPalette.amenities(7); + assertTrue(t7.contains(Material.ENCHANTING_TABLE) && t7.contains(Material.ANVIL)); + assertTrue(t7.containsAll(t5), "Tiers must be cumulative"); + } + @Test void testDeterministicDecoration() { LimitedRegion region = region(); diff --git a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java index 76db95a..cbf7198 100644 --- a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java +++ b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java @@ -12,7 +12,11 @@ import world.bentobox.tradewinds.CommonTestSetup; import world.bentobox.tradewinds.Settings; +import world.bentobox.tradewinds.TestHolds; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.travel.BoatRanks; +import world.bentobox.tradewinds.travel.BoatService; +import world.bentobox.tradewinds.travel.HoldService; /** * Tests bed-less respawns land on the spawn islet, never in the seabed. @@ -33,6 +37,13 @@ public void setUp() throws Exception { when(addon.getSettings()).thenReturn(new Settings()); when(addon.getOverWorld()).thenReturn(world); when(addon.getIslands()).thenReturn(im); + // The loaner boat path + when(addon.getBoatRanks()).thenReturn(new BoatRanks(addon)); + holds = TestHolds.install(addon); + hold = new HoldService(addon); + when(addon.getHoldService()).thenReturn(hold); + boats = mock(BoatService.class); + when(addon.getBoatService()).thenReturn(boats); // The spawn island's spawn point: the market plaza when(im.getSpawnPoint(world)).thenReturn(new Location(world, 72.5, 73, 72.5)); when(mockPlayer.getWorld()).thenReturn(world); @@ -40,6 +51,61 @@ public void setUp() throws Exception { deathBed = new Location(world, 500, 42, 500); } + private HoldService hold; + private BoatService boats; + private TestHolds holds; + + @Test + void testBoatlessRespawnerGetsTheLoaner() { + PlayerRespawnEvent event = new PlayerRespawnEvent(mockPlayer, deathBed, false, false); + listener.onRespawn(event); + org.mockito.Mockito.verify(boats).createFor(mockPlayer, org.bukkit.Material.BAMBOO_RAFT); + } + + @Test + void testBoatWithinReachMeansNoLoaner() { + // Their own boat is moored at the respawn point: no charity needed + var boat = holds.giveBoat(uuid, org.bukkit.Material.OAK_BOAT); + boat.setWorld(world.getName()); + boat.setX(72); + boat.setY(73); + boat.setZ(72); + listener.onRespawn(new PlayerRespawnEvent(mockPlayer, deathBed, false, false)); + org.mockito.Mockito.verify(boats, org.mockito.Mockito.never()).createFor( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void testDistantBoatStillGetsALoaner() { + // Ship an ocean away: lend a raft rather than strand them ashore + var boat = holds.giveBoat(uuid, org.bukkit.Material.OAK_BOAT); + boat.setWorld(world.getName()); + boat.setX(9000); + boat.setZ(9000); + listener.onRespawn(new PlayerRespawnEvent(mockPlayer, deathBed, false, false)); + // A loaner hull exists for them, and it did NOT displace the boat + // they still own an ocean away + org.mockito.Mockito.verify(boats).giveBoatItem(org.mockito.ArgumentMatchers.eq(mockPlayer), + org.mockito.ArgumentMatchers.any()); + org.mockito.Mockito.verify(boats, org.mockito.Mockito.never()).createFor( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void testLoanerDisabledByConfig() { + Settings settings = new Settings(); + settings.setRespawnBoat("NONE"); + when(addon.getSettings()).thenReturn(settings); + listener.onRespawn(new PlayerRespawnEvent(mockPlayer, deathBed, false, false)); + org.mockito.Mockito.verify(boats, org.mockito.Mockito.never()).createFor( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + // An admin typo (off-ladder name) disables rather than granting junk + settings.setRespawnBoat("DIRT"); + listener.onRespawn(new PlayerRespawnEvent(mockPlayer, deathBed, false, false)); + org.mockito.Mockito.verify(boats, org.mockito.Mockito.never()).createFor( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + @Test void testBedlessRespawnGoesToThePlaza() { // NOT the island centre (wooded - players were respawning in treetops) diff --git a/src/test/java/world/bentobox/tradewinds/tasks/BorderCurtainTaskTest.java b/src/test/java/world/bentobox/tradewinds/tasks/BorderCurtainTaskTest.java new file mode 100644 index 0000000..f87cea1 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/tasks/BorderCurtainTaskTest.java @@ -0,0 +1,37 @@ +package world.bentobox.tradewinds.tasks; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.bukkit.Color; +import org.junit.jupiter.api.Test; + +/** + * The curtain's color parsing: forgiving, clamped, and never able to strip + * the border off the sea over a typo. + * + * @author tastybento + */ +class BorderCurtainTaskTest { + + private static final Color FALLBACK = Color.fromRGB(1, 2, 3); + + @Test + void testParsesTriplets() { + assertEquals(Color.fromRGB(255, 64, 64), BorderCurtainTask.parseColor("255,64,64", FALLBACK)); + assertEquals(Color.fromRGB(0, 128, 255), BorderCurtainTask.parseColor(" 0 , 128 , 255 ", FALLBACK)); + } + + @Test + void testClampsOutOfRange() { + assertEquals(Color.fromRGB(255, 0, 255), BorderCurtainTask.parseColor("300,-5,999", FALLBACK)); + } + + @Test + void testFallsBackOnNonsense() { + assertEquals(FALLBACK, BorderCurtainTask.parseColor(null, FALLBACK)); + assertEquals(FALLBACK, BorderCurtainTask.parseColor("", FALLBACK)); + assertEquals(FALLBACK, BorderCurtainTask.parseColor("red", FALLBACK)); + assertEquals(FALLBACK, BorderCurtainTask.parseColor("1,2", FALLBACK)); + assertEquals(FALLBACK, BorderCurtainTask.parseColor("a,b,c", FALLBACK)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/BoatOwnershipTest.java b/src/test/java/world/bentobox/tradewinds/travel/BoatOwnershipTest.java new file mode 100644 index 0000000..93c8011 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/BoatOwnershipTest.java @@ -0,0 +1,257 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +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; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * Ownership, abandonment and protection (rules ruled 2026-08-02): an owned + * boat left in protected island space is untouchable, an unowned one is fair + * game anywhere, and taking a boat abandons the one you had - with its cargo + * still aboard. + * + * @author tastybento + */ +class BoatOwnershipTest extends CommonTestSetup { + + private static final long SEED = 4242L; + + private TradeWinds addon; + private BoatListener listener; + private TestHolds holds; + private GalaxyEngine engine; + private IslandSpec island; + + @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)); + when(addon.getOverWorld()).thenReturn(world); + when(world.getSeed()).thenReturn(SEED); + engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); + holds = TestHolds.install(addon); + when(addon.getFuelService()).thenReturn(new FuelService(addon)); + when(addon.getHoldService()).thenReturn(new HoldService(addon)); + listener = new BoatListener(addon); + island = engine.islandInCell(0, 0).orElseThrow(); + } + + private Location atIsland(int offset) { + Location loc = mock(Location.class); + when(loc.getWorld()).thenReturn(world); + when(loc.getBlockX()).thenReturn(island.centerX() + offset); + when(loc.getBlockZ()).thenReturn(island.centerZ()); + return loc; + } + + @Test + void testOwnedBoatInProtectedSpaceIsProtected() { + BoatHold hold = holds.giveBoat(uuid, Material.OAK_BOAT); + // Moored at the dock: nobody else may board or break it + assertTrue(listener.isProtected(hold, atIsland(100))); + // Out past the protection edge it is on its own + assertFalse(listener.isProtected(hold, atIsland(900))); + } + + @Test + void testUnownedBoatIsNeverProtected() { + BoatHold hull = holds.unownedBoat(Material.CHERRY_CHEST_BOAT); + // Even moored in safe island space, an abandoned hull is fair game - + // this is what makes the OLD BOAT a race to recover + assertFalse(listener.isProtected(hull, atIsland(100))); + } + + @Test + void testTakingABoatAbandonsTheOldOneWithItsCargo() { + BoatHold mine = holds.giveBoat(uuid, Material.OAK_BOAT); + mine.getContents().put("DIAMOND", 12); + BoatHold prize = holds.unownedBoat(Material.CHERRY_CHEST_BOAT); + prize.getContents().put("EMERALD", 5); + + holds.manager().setActiveBoat(uuid, prize); + + // The prize is mine, cargo and all + assertEquals(prize.getUniqueId(), holds.manager().activeBoat(uuid).orElseThrow().getUniqueId()); + assertEquals(uuid.toString(), prize.getOwner()); + assertEquals(5, (int) prize.getContents().get("EMERALD")); + // The boat I left keeps MY cargo, and loses its owner: unowned, + // capturable, and still findable on the chart + assertTrue(mine.isUnowned(), "The abandoned boat is unowned"); + assertEquals(12, (int) mine.getContents().get("DIAMOND"), "Its cargo stays aboard"); + assertEquals(mine.getUniqueId(), holds.manager().oldBoat(uuid).orElseThrow().getUniqueId()); + } + + @Test + void testReclaimingTheOldBoatClearsTheMarker() { + BoatHold first = holds.giveBoat(uuid, Material.OAK_BOAT); + BoatHold second = holds.unownedBoat(Material.SPRUCE_BOAT); + holds.manager().setActiveBoat(uuid, second); + assertTrue(holds.manager().oldBoat(uuid).isPresent()); + // Row back and board the old one again + holds.manager().setActiveBoat(uuid, first); + assertEquals(first.getUniqueId(), holds.manager().activeBoat(uuid).orElseThrow().getUniqueId()); + assertEquals(uuid.toString(), first.getOwner()); + } + + @Test + void testOnlyOneOldBoatIsRemembered() { + BoatHold a = holds.giveBoat(uuid, Material.OAK_BOAT); + BoatHold b = holds.unownedBoat(Material.SPRUCE_BOAT); + BoatHold c = holds.unownedBoat(Material.BIRCH_BOAT); + holds.manager().setActiveBoat(uuid, b); + holds.manager().setActiveBoat(uuid, c); + // Only the most recent abandonment is charted; the first is forgotten + // flotsam (still floating, just nobody's marker) + assertEquals(b.getUniqueId(), holds.manager().oldBoat(uuid).orElseThrow().getUniqueId()); + assertTrue(a.isUnowned()); + } + + @Test + void testTakingABoatStripsItsFormerOwner() { + // Playtest 2026-08-02: player 1 took player 2's boat and player 2 was + // left still pointing at it - so he had phantom cargo slots, fuel + // warnings, and on login the "stolen" path handed the boat BACK by + // unowning it. Losing a boat is not abandoning one. + UUID victim = UUID.randomUUID(); + BoatHold prize = holds.giveBoat(victim, Material.OAK_BOAT); + BoatHold mine = holds.giveBoat(uuid, Material.BAMBOO_RAFT); + + holds.manager().setActiveBoat(uuid, prize); + + assertEquals(uuid.toString(), prize.getOwner(), "The taker owns it"); + assertTrue(holds.manager().activeBoat(victim).isEmpty(), "The victim has no boat"); + assertTrue(holds.manager().oldBoat(victim).isEmpty(), + "A boat someone TOOK is not an abandoned OLD BOAT"); + // The taker's own raft is the one abandoned + assertEquals(mine.getUniqueId(), holds.manager().oldBoat(uuid).orElseThrow().getUniqueId()); + } + + @Test + void testOldBoatMarkerDropsOnceSomeoneClaimsIt() { + BoatHold mine = holds.giveBoat(uuid, Material.OAK_BOAT); + BoatHold other = holds.unownedBoat(Material.SPRUCE_BOAT); + holds.manager().setActiveBoat(uuid, other); + assertTrue(holds.manager().oldBoat(uuid).isPresent(), "Abandoned, so charted"); + // Another sailor takes the hull I abandoned: my marker is a lie now + holds.manager().setActiveBoat(UUID.randomUUID(), mine); + assertTrue(holds.manager().oldBoat(uuid).isEmpty(), "Someone owns it - no OLD BOAT marker"); + } + + @Test + void testTakingABoatWhileCarryingOneMergesRatherThanDuplicating() { + // Playtest 2026-08-02: recovering an old boat while carrying your + // current one left TWO boat items in the pack, both opening the same + // hold, and the spare could be dropped and stripped for free fuel. + when(world.getName()).thenReturn("tradewinds_world"); + when(addon.getMarketService()) + .thenReturn(mock(world.bentobox.tradewinds.economy.MarketService.class)); + when(addon.getMarketService().basePrice(any())).thenReturn(java.util.Optional.of(1.0)); + BoatHold carried = holds.giveBoat(uuid, Material.OAK_BOAT); + carried.getContents().put("COD", 20); + carried.setWorld("tradewinds_world"); + BoatHold found = holds.unownedBoat(Material.CHERRY_CHEST_BOAT); + + // The carried hull is in the pack, stamped with its id. (ItemStack + // meta does not work under MockBukkit - the item factory is a bare + // mock - so the PDC is stubbed directly.) + ItemStack spare = mock(ItemStack.class); + when(spare.getType()).thenReturn(Material.OAK_BOAT); + when(spare.getAmount()).thenReturn(1); + when(spare.hasItemMeta()).thenReturn(true); + org.bukkit.inventory.meta.ItemMeta meta = mock(org.bukkit.inventory.meta.ItemMeta.class); + org.bukkit.persistence.PersistentDataContainer pdc = + mock(org.bukkit.persistence.PersistentDataContainer.class); + when(pdc.get(BoatService.BOAT_ID_KEY, org.bukkit.persistence.PersistentDataType.STRING)) + .thenReturn(carried.getUniqueId()); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(spare.getItemMeta()).thenReturn(meta); + org.bukkit.inventory.PlayerInventory pack = mock(org.bukkit.inventory.PlayerInventory.class); + when(pack.getContents()).thenReturn(new ItemStack[] { spare }); + when(mockPlayer.getInventory()).thenReturn(pack); + when(addon.getBoatService()).thenReturn(new BoatService(addon)); + Location where = mock(Location.class); + when(where.getWorld()).thenReturn(world); + when(where.getX()).thenReturn(0.0); + when(where.getZ()).thenReturn(0.0); + when(where.getBlockX()).thenReturn(0); + when(where.getBlockZ()).thenReturn(0); + when(mockPlayer.getLocation()).thenReturn(where); + when(mockPlayer.getWorld()).thenReturn(world); + + listener.takeBoat(mockPlayer, found); + + // The prize is theirs, the cargo came across, and the spare hull is + // no longer in the pack + assertEquals(found.getUniqueId(), holds.manager().activeBoat(uuid).orElseThrow().getUniqueId()); + assertEquals(20, (int) found.getContents().get("COD"), "Cargo moved into the new hull"); + assertTrue(carried.getContents().isEmpty(), "The old hull was emptied"); + org.mockito.Mockito.verify(spare).setAmount(0); + } + + @Test + void testCargoNeverCrossesTheOcean() { + // Playtest 2026-08-02: a hull thrown to a player on ANOTHER island + // moved its cargo into their boat thousands of blocks away. The + // transfer option is gated on their own boat being alongside. + when(world.getName()).thenReturn("tradewinds_world"); + BoatHold mine = holds.giveBoat(uuid, Material.OAK_BOAT); + mine.setWorld(world.getName()); + mine.setX(0); + mine.setZ(0); + Location here = mock(Location.class); + when(here.getWorld()).thenReturn(world); + // Standing next to my own boat: loading is fine + when(here.getX()).thenReturn(50.0); + when(here.getZ()).thenReturn(0.0); + when(mockPlayer.getLocation()).thenReturn(here); + when(mockPlayer.getWorld()).thenReturn(world); + assertTrue(listener.ownBoatWithinReach(mockPlayer)); + // An island away: not offered at all + when(here.getX()).thenReturn(4000.0); + assertFalse(listener.ownBoatWithinReach(mockPlayer)); + // Another world entirely: likewise + when(here.getX()).thenReturn(50.0); + mine.setWorld("somewhere_else"); + assertFalse(listener.ownBoatWithinReach(mockPlayer)); + } + + @Test + void testAnarchicIslandsProtectNothing() { + BoatHold hold = holds.giveBoat(UUID.randomUUID(), Material.OAK_BOAT); + IslandSpec anarchic = new IslandSpec(9, 9, 60_000, 60_000, island.type(), + SecurityBand.ANARCHIC, island.biomeKey(), "Lawless", 3); + Location loc = mock(Location.class); + when(loc.getWorld()).thenReturn(world); + when(loc.getBlockX()).thenReturn(anarchic.centerX()); + when(loc.getBlockZ()).thenReturn(anarchic.centerZ()); + // No island of ours is at that spot in this galaxy, so nothing shields + // it - the same answer an anarchic port gives + assertFalse(listener.isProtected(hold, loc)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/BoatPickupListenerTest.java b/src/test/java/world/bentobox/tradewinds/travel/BoatPickupListenerTest.java index 8826f90..445d8e9 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/BoatPickupListenerTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/BoatPickupListenerTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test; import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.TestHolds; import world.bentobox.tradewinds.TradeWinds; /** @@ -43,6 +44,8 @@ public void setUp() throws Exception { super.setUp(); addon = mock(TradeWinds.class); when(addon.getOverWorld()).thenReturn(world); + TestHolds.install(addon); + when(addon.getBoatService()).thenReturn(new BoatService(addon)); listener = new BoatPickupListener(addon); inventory = mock(PlayerInventory.class); when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); @@ -72,17 +75,17 @@ void testBoatPickedUpOnTeleport() { } @Test - void testChestBoatCargoComesAlong() { + void testChestBoatTakesOnlyItsHull() { + // The cargo is the RECORD's now, not the chest boat's real inventory, + // so a teleport carries the hull alone - and the hull carries the id ChestBoat boat = (ChestBoat) boat(ChestBoat.class); when(boat.getType()).thenReturn(org.bukkit.entity.EntityType.OAK_CHEST_BOAT); Inventory cargo = mock(Inventory.class); - when(cargo.getContents()).thenReturn(new ItemStack[] { new ItemStack(Material.COAL, 8), null }); when(boat.getInventory()).thenReturn(cargo); listener.onTeleport(teleport()); verify(boat).remove(); - verify(cargo).clear(); - // Hull + one cargo stack - verify(inventory, org.mockito.Mockito.times(2)).addItem(any(ItemStack.class)); + verify(cargo, never()).clear(); + verify(inventory, org.mockito.Mockito.times(1)).addItem(any(ItemStack.class)); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java b/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java index cdd691e..dcd5263 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/BorderPromptListenerTest.java @@ -48,14 +48,16 @@ public void setUp() throws Exception { @Test void testOfferedAtVisibleBorder() { - // The visible border is the protection edge (400) - the offer fires - // exactly where "Now leaving..." appears (playtest regression: it used - // to sit at the range edge ~1000, 550 blocks of dead ocean later) - Optional inside = listener.originIslandNearBorder(spec.centerX() + 390, spec.centerZ()); + // The ring hugs the protection edge (400) tightly now - you have to + // touch the RED curtain (ruled 2026-08-02), so +-5 blocks only + Optional inside = listener.originIslandNearBorder(spec.centerX() + 397, spec.centerZ()); assertTrue(inside.isPresent()); assertEquals(spec, inside.get()); - Optional outside = listener.originIslandNearBorder(spec.centerX() + 410, spec.centerZ()); + Optional outside = listener.originIslandNearBorder(spec.centerX() + 403, spec.centerZ()); assertTrue(outside.isPresent()); + // Just short of the curtain, or just past it: no dialog + assertTrue(listener.originIslandNearBorder(spec.centerX() + 390, spec.centerZ()).isEmpty()); + assertTrue(listener.originIslandNearBorder(spec.centerX() + 410, spec.centerZ()).isEmpty()); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java index 4c3cbe6..3bd62d5 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/ChartNavigationTest.java @@ -64,8 +64,10 @@ void testDockMarkerPointsAtThePier() { assertEquals(Math.atan2((double) pierZ - pz, (double) pierX - px), Math.atan2(marker.dz(), marker.dx()), 0.001); assertEquals((int) Math.hypot((double) pierX - px, (double) pierZ - pz), marker.distance()); - // Below the island names so it never collides with their stack - assertTrue(marker.dy() < 1.2, "Dock marker should hang below the island markers"); + // Below the island names so it never collides with their stack, but + // above the waterline - a sailor at sea level must not see it submerged + assertTrue(marker.dy() < 2.5, "Dock marker should hang below the island markers"); + assertTrue(marker.dy() >= 1.0, "Dock marker must sit above the waterline, not in the sea"); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/travel/FuelServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/FuelServiceTest.java index a58c80e..985c6d7 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/FuelServiceTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/FuelServiceTest.java @@ -3,12 +3,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + import org.bukkit.Material; -import org.bukkit.entity.ChestBoat; -import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.PlayerInventory; import org.junit.jupiter.api.BeforeEach; @@ -17,10 +20,12 @@ import world.bentobox.tradewinds.CommonTestSetup; import world.bentobox.tradewinds.Settings; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.TestHolds; /** - * Tests fuel accounting: hold-only sourcing, cheapest-first consumption, the - * lava-bucket rule, and the no-fuel-outside-the-hold invariant. + * Tests fuel accounting against the virtual hold's fuel slots: hold-only + * sourcing (pockets never count), cheapest-first consumption, the lava-bucket + * rule, and refusing rather than part-burning when short. * * @author tastybento */ @@ -28,11 +33,8 @@ class FuelServiceTest extends CommonTestSetup { private TradeWinds addon; private FuelService service; - private ChestBoat boat; - private Inventory boatInv; - private ItemStack coal; - private ItemStack logs; - private ItemStack lava; + private HoldService hold; + private PlayerInventory pockets; @Override @BeforeEach @@ -40,66 +42,64 @@ public void setUp() throws Exception { super.setUp(); addon = mock(TradeWinds.class); when(addon.getSettings()).thenReturn(new Settings()); + when(addon.getBoatRanks()).thenReturn(new BoatRanks(addon)); + TestHolds holds = TestHolds.install(addon); service = new FuelService(addon); - - coal = new ItemStack(Material.COAL, 4); // 32 units - logs = new ItemStack(Material.OAK_LOG, 10); // 10 units - lava = new ItemStack(Material.LAVA_BUCKET, 1); // 100 units - - boat = mock(ChestBoat.class); - boatInv = mock(Inventory.class); - when(boat.getInventory()).thenReturn(boatInv); - when(boatInv.getContents()).thenReturn(new ItemStack[] { coal, logs, lava, null }); - when(mockPlayer.getVehicle()).thenReturn(boat); - // Player pockets: loose coal that must NOT count (not in the hold) - PlayerInventory pockets = mock(PlayerInventory.class); - when(pockets.getContents()).thenReturn(new ItemStack[] { new ItemStack(Material.COAL, 64), null }); + when(addon.getFuelService()).thenReturn(service); + hold = new HoldService(addon); + when(addon.getHoldService()).thenReturn(hold); + // Player pockets: loose coal that must NOT count (not in the hold), + // and room for returned buckets + pockets = mock(PlayerInventory.class); + when(pockets.addItem(any())).thenReturn(new HashMap<>()); when(mockPlayer.getInventory()).thenReturn(pockets); + // The fuel row: 4 coal (32) + 10 logs (10) + 1 lava bucket (100) = 142 + holds.giveBoat(uuid, Material.OAK_BOAT); + hold.addFuel(mockPlayer, Material.COAL, 4); + hold.addFuel(mockPlayer, Material.OAK_LOG, 10); + hold.addFuel(mockPlayer, Material.LAVA_BUCKET, 1); } @Test - void testHoldFuelCountsOnlyTheHold() { - // 32 + 10 + 100 = 142; the 64 pocket coal (512 units) must not count + void testHoldFuelCountsOnlyTheFuelSlots() { + assertEquals(142.0, service.holdFuel(mockPlayer)); + // Cargo coal is cargo, not fuel + hold.add(mockPlayer, Material.COAL, 64); assertEquals(142.0, service.holdFuel(mockPlayer)); - } - - @Test - void testNoBoatNoFuel() { - when(mockPlayer.getVehicle()).thenReturn(null); - assertEquals(0.0, service.holdFuel(mockPlayer)); - assertFalse(service.consume(mockPlayer, 1)); } @Test void testConsumeCheapestFirst() { // 12 units: all 10 logs (1 each), then 1 coal (8) - overshoot burned assertTrue(service.consume(mockPlayer, 12)); - assertEquals(0, logs.getAmount()); - assertEquals(3, coal.getAmount()); - assertEquals(Material.LAVA_BUCKET, lava.getType()); + Map left = hold.fuelContents(uuid); + assertFalse(left.containsKey(Material.OAK_LOG)); + assertEquals(3, (int) left.get(Material.COAL)); + assertEquals(1, (int) left.get(Material.LAVA_BUCKET)); } @Test void testConsumeRefusesWhenShort() { assertFalse(service.consume(mockPlayer, 143)); - // Nothing was removed - assertEquals(4, coal.getAmount()); - assertEquals(10, logs.getAmount()); + assertEquals(142.0, service.holdFuel(mockPlayer), "Nothing burned on refusal"); } @Test void testLavaBucketLeavesEmptyBucket() { - // 140 units forces the lava bucket to burn - assertTrue(service.consume(mockPlayer, 140)); - assertEquals(Material.BUCKET, lava.getType()); + // 140 burns everything including the lava; the empty bucket comes back + assertTrue(service.consume(mockPlayer, 142)); + assertEquals(0.0, service.holdFuel(mockPlayer)); + org.mockito.ArgumentCaptor given = org.mockito.ArgumentCaptor.forClass(ItemStack.class); + org.mockito.Mockito.verify(pockets).addItem(given.capture()); + assertEquals(Material.BUCKET, given.getValue().getType()); } @Test - void testFuelValues() { - assertEquals(8.0, service.fuelValue(Material.COAL)); - assertEquals(1.0, service.fuelValue(Material.OAK_LOG)); - assertEquals(100.0, service.fuelValue(Material.LAVA_BUCKET)); - assertEquals(0.0, service.fuelValue(Material.DIAMOND)); - assertEquals(0.0, service.fuelValue(Material.DIRT)); + void testEmptyFuelRowMeansNoWarp() { + hold.removeFuel(uuid, Material.COAL, 999); + hold.removeFuel(uuid, Material.OAK_LOG, 999); + hold.removeFuel(uuid, Material.LAVA_BUCKET, 999); + assertEquals(0.0, service.holdFuel(mockPlayer)); + assertFalse(service.consume(mockPlayer, 1)); } } diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java index 258669a..e7e41df 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java @@ -2,28 +2,29 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import org.bukkit.Material; -import org.bukkit.entity.ChestBoat; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.PlayerInventory; 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.TradeWinds; +import world.bentobox.tradewinds.TestHolds; /** - * Tests the hold: chest-boat contents count, pockets never do, removal takes - * from the boat, addition respects inventory space. + * Tests the VIRTUAL hold: capacity comes from the one boat, contents are + * consolidated material→amount in the database, containers and vessels are + * refused, and fuel lives in its own seven slots. * * @author tastybento */ @@ -31,122 +32,206 @@ class HoldServiceTest extends CommonTestSetup { private TradeWinds addon; private HoldService service; - private Inventory boatInv; - private ItemStack wheat; - private ItemStack coal; + private TestHolds holds; @Override @BeforeEach public void setUp() throws Exception { super.setUp(); addon = mock(TradeWinds.class); - when(addon.getSettings()).thenReturn(new Settings()); + Settings settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + when(addon.getBoatRanks()).thenReturn(new BoatRanks(addon)); + when(addon.getFuelService()).thenReturn(new FuelService(addon)); + holds = TestHolds.install(addon); service = new HoldService(addon); + when(addon.getHoldService()).thenReturn(service); + } - wheat = new ItemStack(Material.WHEAT, 40); - coal = new ItemStack(Material.COAL, 10); - ChestBoat boat = mock(ChestBoat.class); - boatInv = mock(Inventory.class); - when(boat.getInventory()).thenReturn(boatInv); - when(boatInv.getContents()).thenReturn(new ItemStack[] { wheat, coal, null }); - when(mockPlayer.getVehicle()).thenReturn(boat); - // Pockets full of diamonds that must never count - PlayerInventory pockets = mock(PlayerInventory.class); - when(pockets.getContents()).thenReturn(new ItemStack[] { new ItemStack(Material.DIAMOND, 64), null }); - when(mockPlayer.getInventory()).thenReturn(pockets); + @Test + void testNoBoatNoHold() { + holds.clearBoat(uuid); + assertNull(service.boat(mockPlayer)); + assertEquals(0, service.capacitySlots(mockPlayer)); + assertEquals(0, service.add(mockPlayer, Material.WHEAT, 10), "No boat means nowhere to stow"); } @Test - void testContentsAreHoldOnly() { - Map contents = service.contents(mockPlayer); - assertEquals(40, contents.get(Material.WHEAT)); - assertEquals(10, contents.get(Material.COAL)); - // Pocket diamonds are invisible to the market (spec principle 1) - assertEquals(0, service.count(mockPlayer, Material.DIAMOND)); + void testBoatSetsCapacity() { + holds.giveBoat(uuid, Material.OAK_BOAT); + assertEquals(3, service.capacitySlots(mockPlayer), "Oak Boat is rank 2: 3 slots"); + holds.giveBoat(uuid, Material.PALE_OAK_CHEST_BOAT); + assertEquals(21, service.capacitySlots(mockPlayer), "Top of the ladder: 21 slots"); } @Test - void testNoBoatNoHold() { - when(mockPlayer.getVehicle()).thenReturn(null); - assertEquals(0, service.count(mockPlayer, Material.WHEAT)); - assertEquals(0, service.remove(mockPlayer, Material.WHEAT, 10)); + void testUpgradeKeepsContents() { + // An upgrade is a refit of the SAME hold - a bigger hull around the + // same cargo, never a new boat + var boat = holds.giveBoat(uuid, Material.OAK_BOAT); + assertEquals(100, service.add(mockPlayer, Material.WHEAT, 100)); + boat.setMaterial(Material.CHERRY_CHEST_BOAT.name()); + assertEquals(20, service.capacitySlots(mockPlayer)); + assertEquals(100, service.count(mockPlayer, Material.WHEAT), + "Upgrading never moves items - the slot count just grows"); + } + + @Test + void testCapacityAndConsolidation() { + holds.giveBoat(uuid, Material.OAK_BOAT); // 3 slots = 192 wheat + assertEquals(192, service.add(mockPlayer, Material.WHEAT, 500), "3 slots x 64"); + assertEquals(0, service.add(mockPlayer, Material.COAL, 1), "Full is full"); + assertEquals(3, service.slotsUsed(uuid)); + // Consolidation: 40 + 30 wheat = 70 = 2 slots, not two spread stacks + holds.giveBoat(uuid, Material.OAK_BOAT); + service.add(mockPlayer, Material.WHEAT, 40); + service.add(mockPlayer, Material.WHEAT, 30); + assertEquals(2, service.slotsUsed(uuid)); + assertEquals(70, service.count(mockPlayer, Material.WHEAT)); + // Headroom math: 70 wheat in 2 slots leaves 58 headroom + 1 free slot + assertEquals(58 + 64, service.capacityFor(uuid, Material.WHEAT)); + } + + @Test + void testUnstackablesEatSlots() { + holds.giveBoat(uuid, Material.OAK_BOAT); // 3 slots + assertEquals(3, service.add(mockPlayer, Material.IRON_SWORD, 5), + "Unstackables occupy a slot each"); + } + + @Test + void testContainersAndVesselsRefused() { + holds.giveBoat(uuid, Material.PALE_OAK_CHEST_BOAT); + assertEquals(0, service.add(mockPlayer, Material.BUNDLE, 1)); + assertEquals(0, service.add(mockPlayer, Material.SHULKER_BOX, 1)); + assertEquals(0, service.add(mockPlayer, Material.WHITE_SHULKER_BOX, 1)); + assertEquals(0, service.add(mockPlayer, Material.CHEST, 1)); + assertEquals(0, service.add(mockPlayer, Material.OAK_BOAT, 1), "A vessel is not cargo"); + assertEquals(0, service.add(mockPlayer, Material.BAMBOO_RAFT, 1)); + assertTrue(service.refuses(Material.BARREL)); + } + + @Test + void testRemoveIsExactAndBounded() { + holds.giveBoat(uuid, Material.OAK_BOAT); + service.add(mockPlayer, Material.COD, 50); + assertEquals(30, service.remove(mockPlayer, Material.COD, 30)); + assertEquals(20, service.count(mockPlayer, Material.COD)); + assertEquals(20, service.remove(mockPlayer, Material.COD, 999)); + assertEquals(0, service.count(mockPlayer, Material.COD)); + assertEquals(0, service.remove(mockPlayer, Material.COD, 1)); + } + + @Test + void testFuelRow() { + holds.giveBoat(uuid, Material.OAK_BOAT); + assertEquals(8, service.addFuel(mockPlayer, Material.COAL, 8)); + assertEquals(8, (int) service.fuelContents(uuid).get(Material.COAL)); + // Non-fuel is refused from the fuel row + assertEquals(0, service.addFuel(mockPlayer, Material.WHEAT, 8)); + // Cargo capacity is untouched by fuel + assertEquals(3, service.capacitySlots(mockPlayer)); + assertEquals(0, service.slotsUsed(uuid)); + // Fuel moves freely back out + assertEquals(5, service.removeFuel(uuid, Material.COAL, 5)); + assertEquals(3, (int) service.fuelContents(uuid).get(Material.COAL)); + } + + @Test + void testCargoFuelMovesToTheFuelRow() { + // Coal bought at market lands in CARGO; the GUI gesture moves it to + // the fuel row - fuel is exempt from the one-way rule (Ben's ruling) + holds.giveBoat(uuid, Material.OAK_BOAT); + service.add(mockPlayer, Material.COAL, 100); + assertEquals(2, service.slotsUsed(uuid)); + assertEquals(100, service.moveCargoToFuel(mockPlayer, Material.COAL, 100)); + assertEquals(0, service.slotsUsed(uuid), "Cargo slots freed"); + assertEquals(100, (int) service.fuelContents(uuid).get(Material.COAL)); + // Non-fuel cargo can never take this exit + service.add(mockPlayer, Material.WHEAT, 10); + assertEquals(0, service.moveCargoToFuel(mockPlayer, Material.WHEAT, 10)); + // And the move respects the fuel row's capacity + service.addFuel(mockPlayer, Material.LAVA_BUCKET, 6); // coal(2 slots)+6 lava = 8 > 7... coal is 2 slots, so 5 fit + service.add(mockPlayer, Material.COAL, 640); + assertTrue(service.moveCargoToFuel(mockPlayer, Material.COAL, 640) < 640, + "A full fuel row bounds the move"); } @Test - void testExpandersAreCargoWhereverCarried() { - // A cargo expander in the pack is hold: the sailor need not be sitting - // in a chest boat to have somewhere to put goods - ItemStack expander = mock(ItemStack.class); - when(expander.getType()).thenReturn(Material.SHULKER_BOX); - when(expander.hasItemMeta()).thenReturn(true); - org.bukkit.inventory.meta.ItemMeta meta = mock(org.bukkit.inventory.meta.ItemMeta.class); - org.bukkit.persistence.PersistentDataContainer pdc = - mock(org.bukkit.persistence.PersistentDataContainer.class); - when(pdc.has(org.bukkit.NamespacedKey.fromString("tradewinds:expander"), - org.bukkit.persistence.PersistentDataType.STRING)).thenReturn(true); - when(meta.getPersistentDataContainer()).thenReturn(pdc); - when(expander.getItemMeta()).thenReturn(meta); - - PlayerInventory pockets = mock(PlayerInventory.class); - when(pockets.getContents()).thenReturn(new ItemStack[] { expander, null }); - when(mockPlayer.getInventory()).thenReturn(pockets); - when(mockPlayer.getVehicle()).thenReturn(null); - - assertEquals(1, service.expanders(mockPlayer).size()); + void testFuelSlotsAreSeven() { + // Lava buckets do not stack: seven fill the row, the eighth is refused + holds.giveBoat(uuid, Material.OAK_BOAT); + assertEquals(7, service.addFuel(mockPlayer, Material.LAVA_BUCKET, 8)); + assertEquals(0, service.addFuel(mockPlayer, Material.COAL, 1), "Row full"); } @Test - void testExpandersAreRecognisedByAnyShulkerColour() { - // Expanders ship WHITE to differentiate them from vanilla purple, but - // purple ones bought by earlier builds must keep working - for (Material colour : new Material[] { Material.WHITE_SHULKER_BOX, Material.SHULKER_BOX }) { - ItemStack expander = mock(ItemStack.class); - when(expander.getType()).thenReturn(colour); - when(expander.hasItemMeta()).thenReturn(true); - org.bukkit.inventory.meta.ItemMeta meta = mock(org.bukkit.inventory.meta.ItemMeta.class); - org.bukkit.persistence.PersistentDataContainer pdc = - mock(org.bukkit.persistence.PersistentDataContainer.class); - when(pdc.has(org.bukkit.NamespacedKey.fromString("tradewinds:expander"), - org.bukkit.persistence.PersistentDataType.STRING)).thenReturn(true); - when(meta.getPersistentDataContainer()).thenReturn(pdc); - when(expander.getItemMeta()).thenReturn(meta); - assertTrue(service.isExpander(expander), colour + " should be a valid expander"); - } - // A plain shulker box with no stamp is not cargo space - ItemStack plain = new ItemStack(Material.WHITE_SHULKER_BOX); - assertFalse(service.isExpander(plain)); + void testExpanderInstallRules() { + holds.giveBoat(uuid, Material.CHERRY_CHEST_BOAT); + assertFalse(service.canInstallExpander(uuid), "Only the Pale Oak Chest Boat takes expanders"); + holds.giveBoat(uuid, Material.PALE_OAK_CHEST_BOAT); + assertTrue(service.installExpander(uuid)); + assertEquals(1, service.expanderCount(uuid)); + // An installed expander occupies one cargo slot + assertEquals(1, service.slotsUsed(uuid)); + assertEquals(20, service.slotsFree(uuid)); } @Test - void testRemove() { - assertEquals(25, service.remove(mockPlayer, Material.WHEAT, 25)); - assertEquals(15, wheat.getAmount()); - // Removing more than present takes what there is - assertEquals(15, service.remove(mockPlayer, Material.WHEAT, 99)); + void testExpanderSpilloverAndAggregation() { + holds.giveBoat(uuid, Material.PALE_OAK_CHEST_BOAT); + service.installExpander(uuid); + // Main capacity: 20 free slots x 64 = 1280; expander adds 21 x 64 = 1344 + assertEquals(1280 + 1344, service.capacityFor(uuid, Material.WHEAT)); + assertEquals(2624, service.add(mockPlayer, Material.WHEAT, 9999)); + // Aggregated count sees everything; main contents map does not + assertEquals(2624, service.count(mockPlayer, Material.WHEAT)); + assertEquals(1280, (int) service.contents(uuid).get(Material.WHEAT)); + assertEquals(1344, (int) service.expanderContents(uuid, 0).get(Material.WHEAT)); + assertEquals(2624, (int) service.tradeContents(mockPlayer).get(Material.WHEAT)); + // Removal drains the main hold first, then the expander + assertEquals(1300, service.remove(mockPlayer, Material.WHEAT, 1300)); + assertEquals(0, service.contents(uuid).getOrDefault(Material.WHEAT, 0)); + assertEquals(1324, (int) service.expanderContents(uuid, 0).get(Material.WHEAT)); } @Test - void testFreeSpace() { - // Boat: wheat 40/64 (+24), coal 10/64 (+54), one empty slot (+64) - when(boatInv.getStorageContents()).thenReturn(new ItemStack[] { wheat, coal, null }); - assertEquals(24 + 54 + 64, service.freeSpace(mockPlayer)); - // No boat, no bundles: no hold at all - when(mockPlayer.getVehicle()).thenReturn(null); - assertEquals(0, service.freeSpace(mockPlayer)); + void testExpanderDestroyOnlyWhenEmpty() { + holds.giveBoat(uuid, Material.PALE_OAK_CHEST_BOAT); + service.installExpander(uuid); + service.addToExpander(mockPlayer, 0, Material.COD, 10); + assertFalse(service.destroyExpander(uuid, 0), "The TNT refuses a loaded expander"); + service.removeFromExpander(uuid, 0, Material.COD, 10); + assertTrue(service.destroyExpander(uuid, 0)); + assertEquals(0, service.expanderCount(uuid)); } @Test - void testAddUsesBoatInventory() { - when(boatInv.addItem(org.mockito.ArgumentMatchers.any(ItemStack.class))).thenReturn(new HashMap<>()); - assertEquals(16, service.add(mockPlayer, new ItemStack(Material.STONE, 16))); + void testInertExpandersKeepContents() { + holds.giveBoat(uuid, Material.PALE_OAK_CHEST_BOAT); + service.installExpander(uuid); + service.addToExpander(mockPlayer, 0, Material.DIAMOND, 5); + // After ending up with a smaller boat the expander rides along inert: + // not openable, contents intact and still counted aboard + service.active(uuid).orElseThrow().setMaterial(Material.OAK_BOAT.name()); + assertFalse(service.expandersOpenable(uuid)); + assertEquals(5, (int) service.expanderContents(uuid, 0).get(Material.DIAMOND)); + assertEquals(5, service.count(mockPlayer, Material.DIAMOND)); } @Test - void testAddReportsLeftover() { - // Boat rejects 10 of 16 - HashMap leftover = new HashMap<>(); - leftover.put(0, new ItemStack(Material.STONE, 10)); - when(boatInv.addItem(org.mockito.ArgumentMatchers.any(ItemStack.class))).thenReturn(leftover); - assertEquals(6, service.add(mockPlayer, new ItemStack(Material.STONE, 16))); + void testFuelServiceBurnsCheapestFirst() { + holds.giveBoat(uuid, Material.OAK_BOAT); + service.addFuel(mockPlayer, Material.COAL, 4); // 4 x 8 = 32 units + service.addFuel(mockPlayer, Material.OAK_LOG, 10); // 10 x 1 = 10 units + FuelService fuel = addon.getFuelService(); + assertEquals(42.0, fuel.holdFuel(mockPlayer), 1e-9); + // Burning 6 units takes the cheap logs first + assertTrue(fuel.consume(mockPlayer, 6)); + assertEquals(4, (int) service.fuelContents(uuid).getOrDefault(Material.OAK_LOG, 0)); + assertEquals(4, (int) service.fuelContents(uuid).get(Material.COAL)); + // Too little is refused outright, nothing burned + assertTrue(!fuel.consume(mockPlayer, 999)); + assertEquals(36.0, fuel.holdFuel(mockPlayer), 1e-9); } } diff --git a/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java b/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java index db7c433..0479a6c 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java @@ -107,4 +107,29 @@ void testDestinationCap() { <= list.get(i).island().distanceSquared(origin.centerX(), origin.centerZ())); } } + + @org.junit.jupiter.api.Test + void testArrivalYawFacesTheTarget() { + // Minecraft yaw: 0 = south (+Z), -90 = east (+X), 90 = west, ±180 = north + assertEquals(0.0f, WarpService.yawToward(0, 0, 0, 100), 0.01f); + assertEquals(-90.0f, WarpService.yawToward(0, 0, 100, 0), 0.01f); + assertEquals(90.0f, WarpService.yawToward(0, 0, -100, 0), 0.01f); + assertEquals(180.0f, Math.abs(WarpService.yawToward(0, 0, 0, -100)), 0.01f); + // A diagonal: north-east is -135 + assertEquals(-135.0f, WarpService.yawToward(0, 0, 100, -100), 0.01f); + } + + @org.junit.jupiter.api.Test + void testTheRealWarpArrivalFromTheLog() { + // The 2026-08-02 console: arriving (-2919, 2187) with the dock flag at + // (-3237, 2497). The sailor's own F3 read 46.3 facing the dock, so the + // plain look-at yaw is the answer - no hull offset (that "fix" turned + // the boat the other way and had to be reverted). + assertEquals(45.7f, WarpService.yawToward(-2919, 2187, -3237, 2497), 0.2f); + // Wrapping still hands back Minecraft's own (-180, 180] + for (int deg = -360; deg <= 360; deg += 17) { + float wrapped = WarpService.normalise(deg); + assertTrue(wrapped > -180.01f && wrapped <= 180.01f, "Out of range: " + wrapped); + } + } } diff --git a/tradewinds-design-decisions.md b/tradewinds-design-decisions.md index aeb6bd0..18afe19 100644 --- a/tradewinds-design-decisions.md +++ b/tradewinds-design-decisions.md @@ -24,6 +24,7 @@ These are the reasons the design works. If a future change breaks one of these, - Base generators: AcidIsland-style ocean/ocean-floor (no acid) for both the overworld and the dedicated Nether-environment interstice world. No End. - Unowned BentoBox islands with protection flags per **security band** (EVE model: safe → less safe → anarchic); no block place/break normally; island name (Elite-style procedural) announced on entry. - Reference dimensions from AcidIsland framing: ~1000 range, ~400 protection radius, border shown at protection edge. Islands never within sight of each other. +- **Tech level** per island (default scale 1–7), seeded, correlated with island type (industrial skews high, agricultural low, with variance — "Advanced Agricultural" exists). Shown in the entry announcement and chart data: *"Esedaxe — Advanced Industrial (Tech 6), Safe."* Tech gates **shops only, never docking or use**: boat ranks sold up to TL × 3 (TL7 = all twenty), expanders sold only at top-tech islands. Tech also modulates prices — high-tech sells finished goods cheap and buys raw dear, low-tech the inverse — so the best routes are tech *differentials* and route planning reads type × tech × security off the chart. Starter cluster guarantees at least one ~TL3 island. Crafting deliberately bypasses shop tech gates: shop-with-tech is the money route to a big boat, finding rare wood is the exploration route. ### Travel - Border is **passable**; rowing between/past islands is free. Reaching the border in a boat offers the warp dialog (Paper 1.21.6 dialog API); rowing through does nothing special. diff --git a/tradewinds-hold-plan.md b/tradewinds-hold-plan.md new file mode 100644 index 0000000..63184d9 --- /dev/null +++ b/tradewinds-hold-plan.md @@ -0,0 +1,185 @@ +# TradeWinds — Virtual Hold & Boat Mechanics: Implementation Plan +*For Claude Code. Merged from Ben's "# Boat mechanics.md" (authoritative on conflicts) and the earlier virtual-hold plan. Server-authoritative holds in the database; no real containers ever hold cargo. Clean-slate build, no migration.* + +## Core model + +**One Boat Rule: a player has exactly one boat, or none.** The hold is conceptually the *player's* cargo inventory; the boat they possess only sets its size. Upgrading never moves items — the slot count grows and existing contents stay put. + +Storage is database-backed (BentoBox Database API); the boat item/entity is a key. While a player owns a boat, the hold is keyed to the player; when the boat becomes a dropped item, the hold contents travel with the item (item-keyed record with the hold UUID in the ItemStack PDC) until picked up or expired. + +## Boat ranks (defaults; `config.yml` map `boat-material → slots`) + +| Rank | Boat | Slots | | Rank | Boat | Slots | +|---|---|---|---|---|---|---| +| 1 | Bamboo Raft | 2 | | 11 | Dark Oak Boat | 12 | +| 2 | Oak Boat | 3 | | 12 | Jungle Chest Boat | 13 | +| 3 | Spruce Boat | 4 | | 13 | Mangrove Boat | 14 | +| 4 | Bamboo Chest Raft | 5 | | 14 | Acacia Chest Boat | 15 | +| 5 | Birch Boat | 6 | | 15 | Cherry Boat | 16 | +| 6 | Oak Chest Boat | 7 | | 16 | Dark Oak Chest Boat | 17 | +| 7 | Jungle Boat | 8 | | 17 | Pale Oak Boat | 18 | +| 8 | Spruce Chest Boat | 9 | | 18 | Mangrove Chest Boat | 19 | +| 9 | Acacia Boat | 10 | | 19 | Cherry Chest Boat | 20 | +| 10 | Birch Chest Boat | 11 | | 20 | Pale Oak Chest Boat | 21 | + +## Expanders + +- Install **only in a Pale Oak Chest Boat** (the top rank). An installed expander **occupies one C slot**; clicking it opens a nested inventory panel — same style, own TNT destroy slot, **no fuel row**. +- Proposed defaults *(veto welcome)*: nested inventory is **21 slots** (net +20 per expander); expanders cannot contain other expanders or any container item; an expander can be dragged to TNT **only when empty**; nested inventories are openable only while the expander sits in a Pale Oak Chest Boat's hold (an expander that ends up elsewhere via drop/pickup rides along as inert cargo, contents preserved, until installed again or destroyed); sold at shops only, and only at **top-tech (TL7) islands** — the endgame sink has a home port. +- No installed-count cap needed: the 21 C slots are the natural ceiling, and doubling prices (below) make the tail aspirational. A fully expanded ship approaches ~440 slots and a nine-figure spend — the "guard it with your life" endgame. + +## Pricing (defaults; formulas in config) + +- **Boats — quadratic:** `price = 25 × slots²`. Anchors: Bamboo Raft 100, Birch 900, Jungle 1,600, Mangrove 4,900, Pale Oak 8,100, Cherry Chest 10,000, Pale Oak Chest 11,025. Early upgrades are pocket change; the top rungs cost real trading profit but stay reachable — correct, since boats are also craftable and the shop is a convenience for players who haven't found the rarer groves. +- **Expanders — doubling:** `price = 5,000 × 2^(installed)`. First 5,000; fifth 80,000; eighth 640,000; tenth 2.56M. The curve *is* the endgame money sink — no cap required, the wallet is the cap. + +## Hold GUI (BentoBox Panel API) + +Six rows: + +``` +X T X X X X X X X +X C C C C C C C X +X C C C C C C C X +X C C C C C C C X +X X X X X X X X X +X F F F F F F F X +``` + +- **T — TNT destroy slot.** Lore: "Drag cargo here to destroy them." Dragging cargo onto it removes the items from the game (destroyed, NOT dropped into the world). Fuel dragged here is never destroyed. +- **C — cargo slots**, up to 21, unlocked left-to-right/top-down to the boat's capacity; locked slots render as border panes (X). +- **F — fuel slots**, 7, shown as pale-red glass panes with lore "Fuel Slot" when empty. Fuel dropped anywhere in the GUI routes to a fuel slot and stacks where possible. Fuel is freely added, moved, and **removed** — fuel does not compete with cargo and is not subject to one-way rules. Lava buckets occupy a fuel slot each; on consumption the lava is burned and the empty bucket returned. +- **X — light-blue glass border**, no lore. + +**Contents auto-consolidate:** items are always stored in optimal stacks, never spread — slot usage is always minimal. + +### Removal rules +Cargo can leave the hold only by being **sold** or **destroyed** (TNT slot). Transfers out to player inventory, drops, hotkeys, shift-clicks, and hopper-adjacent tricks all must fail (adversarial test list required). + +### Insertion rules +**Any item may be added to the hold, one-way**, except pouches, bundles, shulker boxes, or any other container item (they would expand effective hold size). Sole exception: official Cargo Expander items, in Pale Oak Chest Boats only. If an island won't buy what you inserted, it simply wastes space until you destroy it. Customs stamping is **removed entirely** — capacity and the stock/demand pools carry the balance. + +## Opening the hold +- Sitting in the boat + right-click — chest boat or not. +- Sneak + right-click the boat entity. +- Right-click the boat item in your inventory. + +## Obtaining and upgrading boats + +Three routes: **shop**, **craft**, **pick up a dropped boat item**. + +### Shop +- Shops list only boats **bigger** than your current boat, priced per the quadratic formula above, and only up to the island's **tech level**: ranks sold ≤ TL × 3 on a 1–7 scale (config), so top boats exist only at high-tech ports. Tech gates shops only — never docking, riding, or crafting. +- Purchase replaces your current boat — **the replaced boat is destroyed**; you are buying an upgrade, not a second boat. (Grants a boat if you had none.) Contents persist untouched — capacity simply increases. Example: Pale Oak Boat (18 slots, full of granite) upgraded to Cherry Chest Boat (20) → same 18 stacks of granite, 2 new empty slots. + +### Crafting +- Same replacement semantics. Crafting a boat **smaller** than your current boat is blocked (craft event cancelled with a locale message). +- A player may add a chest to their current boat at any time (vanilla boat+chest recipe) — an in-place upgrade to the chest variant's capacity. + +### Dropped boat items +Boat items exist when thrown, dropped on death, or when a boat entity is broken. The hold contents travel with the item. + +1. **TTL:** every dropped boat item gets an expiry timestamp **stored in the database** (survives restarts/crashes). A periodic task removes overdue items by UUID, along with their contents. +2. Pickup, item **bigger** than your boat: it immediately replaces your boat — **the replaced boat is destroyed** (upgrade semantics, same as the shop); your capacity grows; your existing contents remain and the captured contents merge in. +3. Pickup, item **smaller or equal**: you keep your boat; contents transfer from the captured item **most-valuable-first** (BlueBook base price as the value oracle) until your hold is full. +4. If your hold fills mid-transfer, you're told the transfer is partial; the remainder stays in the dropped item (still under TTL) for anyone — including you, after destroying or selling to make room. +5. **Sanctioned use:** dropping a loaded boat so another player can pick it up is the intended way to hand goods over. Not an exploit. +6. Pickup with **no current boat**: the item becomes your boat with its contents intact *(assumption — confirm)*. + +### Edge cases (from Ben's doc) +- If the dropped item entity is destroyed by external means (another plugin, etc.), the inventory is lost — accept and clean the DB record. +- Boats must never be destroyed **except by lava**: trident hits, collisions, and other breakage always yield the item. +- On player death, the boat drops as an item with contents (except lava deaths). **`keepInventory` servers/regions:** the player simply keeps their boat and hold. **DeathChest servers:** the boat goes into the death chest; the player must buy a boat and fuel and physically travel there (no teleporting per DeathChest rules); on retrieving their old boat, normal pickup/upgrade rules apply and the recovery boat — being smaller — is the one removed. +- A mob (e.g. zombie) picking up the boat item: **works for free under this model** — contents are DB-keyed via the ItemStack's PDC, which survives mob pickup; kill the zombie, the item drops, hold intact. TTL handling while mob-held: pause or extend TTL while an entity holds it (recommend: pause). +- Overfull situations (e.g. capacity shrinks by admin action): player must choose what to keep; open the GUI in triage mode where excess must be destroyed before normal use resumes. + +## Data model + +``` +PlayerHold (DataObject) DroppedBoat (DataObject) + uniqueId : player UUID uniqueId : hold/item UUID (in item PDC) + boat : material or none boat : material + contents : [{material, amt}] contents : [{material, amt}] + fuel : [{material, amt}] fuel : [{material, amt}] + expiresAt : timestamp (TTL) +``` +Ownership transitions (drop → pickup → replace) are atomic DB moves between the two objects. Duplicating a key item cannot duplicate cargo — contents exist once, server-side. + +## Trade & customs integration +- Plaza trading transacts against the player's hold — with One Boat there is never ambiguity. +- Customs scans the player's hold (and the dropped-boat record if they're carrying a boat item — not possible under One Boat, but guard anyway). +- Smuggler evidence-dumping = destroying contraband via the TNT slot before police close. +- Fuel checks (warp dialog, low-fuel warnings) read the fuel slots only. + +## Resolved rulings (this revision) +1. Expanders **kept**, Pale-Oak-Chest-only, nested-inventory model (see Expanders). +2. Boat pricing **quadratic**, expanders **doubling** (see Pricing) — proposed defaults awaiting veto. +3. Replaced boats are **destroyed** — purchases and bigger-boat pickups are upgrades. +4. Customs stamping **removed entirely**. +5. `keepInventory` / DeathChest behavior defined (see edge cases). +6. Destroy-only jettison confirmed. + +Remaining proposed-not-ruled defaults, all in the Expanders section: nested size 21, no nesting, empty-before-destroy, openable only while installed in a Pale Oak Chest Boat, sold at industrial islands. + +## Rulings 2026-08-01 (Ben, in session) + +1. Pouches/bundles fully retired — the boat is the only hold sizer. +2. **Start kit: Oak Boat (rank 2, 3 slots)**; charity/destitution grants a **Bamboo Raft (rank 1)**. +3. Expander home port: **TL7 islands regardless of type** (supersedes "industrial islands" above). +4. Tech price modulation: **±3% per TL step from TL4** (config `economy.tech-price-step`), finished categories (METALS, FOOD) cheaper / raw (ORES, CROPS, WOOD, FISH, STONE) dearer as TL rises. +5. Jettison is **destroy-only** (TNT slot) — a last-ditch option when you cannot beat the police or escape. Nothing floats. +6. Higher-tech plazas gain amenities: all ports have the galley (workbench + campfire); TL3+ furnace + stonecutter, TL4+ smithing table + grindstone, TL5+ brewing stand + cauldron, TL6+ anvil, TL7 enchanting table with bookshelves. +7. **Bigger-boat pickup is a SWAP, not a destruction** (supersedes "the replaced boat is destroyed" for the PICKUP path only - shop purchases remain trade-ins): you take the bigger hull keeping your own cargo; the wreck's cargo merges in most-valuable-first; the overflow goes over the side in your OLD boat, which floats (tagged, fresh TTL) where the wreck was. The old hull can always take the overflow - your kept cargo displaced at most that many slots - so **nothing is ever lost to the sea**. + +## Acceptance tests +- One Boat invariant holds through every path: shop, craft, pickup, death, admin gives. +- Upgrade (shop/craft/chest-add) preserves contents exactly; smaller purchases/crafts impossible. +- Dropped-item TTL fires across a server restart; mob-held items survive with contents. +- No GUI interaction sequence extracts cargo to player inventory; fuel moves freely both ways; TNT destroys cargo, never fuel. +- Pickup transfer is most-valuable-first, partial transfers leave the remainder intact and claimable. +- Death always yields the boat item with contents (except lava); trident/collision yields the item, never destruction. +- Expanders: installable only in Pale Oak Chest Boats; nested GUI never accepts containers or expanders; TNT refuses a non-empty expander; nth-expander price = base × 2ⁿ; expander riding in a smaller boat after pickup is inert but its contents survive round-trips. +- Tech gating: a TL n shop never lists boats above rank 3n or (below TL7) expanders; crafting is unaffected by tech level. + +## Rulings 2026-08-02 (abandonment, capture, proximity) — Ben, in session + +**Finding a hull (revised 2026-08-02, supersedes the smaller-boat rule):** +picking up OR boarding a boat that is not yours always opens a dialog whose +options depend on how far away your own boat is: +1. **Take the boat** - it becomes yours; your own becomes an unowned OLD BOAT + where it lies. Always available, whatever the sizes. +2. **Take only the cargo** - moved into your own hold, hull left behind. + Offered ONLY when your own boat is within `boats.cargo-transfer-range` + (default 200 blocks) and the hull actually carries something: cargo never + crosses the ocean on its own. +3. **Leave it alone.** +A player with no boat at all claims the hull outright, no dialog - there is +nothing to lose. + +**Trading proximity:** you cannot trade at an island unless you have a boat +within that island's space (BentoBox island check). No boat there, no market. + +**Abandonment (boat left unattended):** +1. `/tw chart` shows your active BOAT wherever you are in the world, like the + DOCK marker. +2. Unattended inside island space: protected — no other player may board it, + and it cannot be broken, even by mobs. Applies to every band except + ANARCHIC. +3. Unattended outside island space: fair game for capture. Still technically + yours, but **it cannot be used for trading** (it is not in island space). + Board a different boat and the old one is no longer your main boat: the + chart shows it as OLD BOAT until another player takes it or it is + destroyed. This is the death-recovery route: leave the boat at a dock, die + ashore, respawn at spawn, take a bamboo raft, row back, board the old boat. +4. A player who boards an unattended unprotected boat **acquires it and + everything in it**. Their previous boat becomes OLD BOAT on their chart. +5. Riding as a *passenger* never takes over a boat. + +**Logging off:** +1. In a protected area: the boat is protected while you are offline. +2. Outside protection, or on an ANARCHIC island: the boat stays in the world + and can be taken. On next login the player is told it was taken/stolen; if + they have no boat and are in water, they are given a bamboo raft to paddle + with. +3. Players who want certainty must dismount, break the boat and carry it in + their inventory before logging out. diff --git a/tradewinds-overview.md b/tradewinds-overview.md index 72bc02f..5ab5710 100644 --- a/tradewinds-overview.md +++ b/tradewinds-overview.md @@ -1,12 +1,12 @@ # TradeWinds ### A BentoBox gamemode of sea trading, smuggling, and piracy -**The pitch:** Your players are merchant sailors in an endless procedurally generated ocean dotted with trading islands. They start with nothing but a boat and a single trading bundle, and build a fortune by buying goods cheap on one island and selling them dear on another. How they get rich is up to them — honest hauling, smuggling contraband past customs patrols, hunting bounties, or outright piracy. Every choice has a price, and the game keeps score. +**The pitch:** Your players are merchant sailors in an endless procedurally generated ocean dotted with trading islands. They start with nothing but a rowing boat — the boat IS the cargo hold — and build a fortune by buying goods cheap on one island and selling them dear on another. How they get rich is up to them — honest hauling, smuggling contraband past customs patrols, hunting bounties, or outright piracy. Every choice has a price, and the game keeps score. --- ## The world -The ocean is generated from a single shareable seed. Trading islands appear at random positions across the ocean — sparsely scattered, and always far enough apart that you can never see one island from another. Each has its own biome, procedurally generated name, and economic character: farm islands sell food cheap, industrial islands want raw materials, and so on. Islands generate lazily as players push outward, so the world grows with your community. Villagers run the markets; the islands themselves are protected — no building or breaking. +The ocean is generated from a single shareable seed. Trading islands appear at random positions across the ocean — sparsely scattered, and always far enough apart that you can never see one island from another. Each has its own biome, procedurally generated name, and economic character: farm islands sell food cheap, industrial islands want raw materials, and so on. Islands also carry a **tech level** — only advanced ports sell the bigger ships, so wealth alone won't upgrade you; you have to sail somewhere that can build it. Islands generate lazily as players push outward, so the world grows with your community. Villagers run the markets; the islands themselves are protected — no building or breaking. Each island has a **security level**, from *safe* (no PvP, no hostile mobs, heavy policing) down to *anarchic* (anything goes). The best profit margins — and the black markets — are in dangerous space. @@ -15,12 +15,12 @@ Boats are everything. Players row freely between islands, or reach an island's b Warps occasionally *fail*, dropping the player into a hostile Nether sea mid-route with Ghasts inbound. Fight or re-engage the warp — the detour is free, the fright isn't. -Cargo grows in stages: bundles → chest boat → purchasable cargo expanders, with costs that scale steeply. A maxed-out trade ship carries a fortune and is worth defending — or attacking. +Cargo grows in stages: twenty boat ranks from a two-slot bamboo raft to the Pale Oak Chest Boat, then installable cargo expanders, with costs that scale steeply. A maxed-out trade ship carries a fortune and is worth defending — or attacking. ## Law and disorder Every player has a **reputation**. Attacking villagers, killing innocents, or trading contraband (sugar, by default) drives it down; clean living slowly restores it. Slip too far and you're *Wanted*: open to PvP everywhere, carrying a bounty any player can collect, and met at civilized islands by the police — Iron Golems on land, Guardians at sea, Phantoms in the air. A wanted player's bounty hangs right over their head for all to see (configurable) — a target sign for hunters, a badge of infamy for pirates. -Entering an island's waters triggers a **customs scan**. Get flagged with contraband and the patrol launches — flee, fight, or dump the evidence overboard before they close in. Smuggling is a skill, not a tax. +Entering an island's waters triggers a **customs scan**. Get flagged with contraband and the patrol launches — flee, fight, or destroy the evidence before they close in (a last-ditch option: nothing floats, nothing is recovered). Smuggling is a skill, not a tax. Players who earn enough can **buy their own island**, placed in the open ocean between trading islands, and eventually sell their own crafted goods into the economy. From 170f0682f2dc96518208a33a261bfed33e6ba125 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 09:25:39 -0700 Subject: [PATCH 075/112] Bring README and CLAUDE.md up to date with the current game The README still described a starter "trading bundle" and pointed at three docs; it now explains what the game actually is - the boat as the hold, seeded galaxy, tech levels, whole-coin trade, capture rules - lists every design doc, and says plainly that the data model is not stable yet. CLAUDE.md gains the two things a fresh session most needs and cannot infer: the boat/hold architecture (records keyed by PDC id on entity AND item, stamp every new hull, match by identity not material, losing != abandoning, money is whole coins via the Vault formatter), and the test traps that each cost a playtest - ItemStack meta being inert under MockBukkit, never testing against a hand-written manager stand-in, the `world` field shadowing the package root, PlayerInteractEvent at AIR arriving pre-cancelled, and YAML 1.1 treating yes/no as booleans. Also records the deploy script's log-quiet guard and the clean-slate rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 61 +++++++++++++++++++++++++++++++++++++---- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 52edd08..862c644 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,15 @@ Deploy for in-game testing: `./scripts/deploy.sh` (builds, then installs to refuses to. The plugin classloader reads classes lazily from the jar, so replacing it invalidates the handle and any not-yet-loaded class throws `NoClassDefFoundError`. When that lands inside chunk generation, Paper calls it -an unrecoverable chunk system failure and stops the server. +an unrecoverable chunk system failure and stops the server. The script has a +second guard: it also refuses while `latest.log` has been written in the last +minute, so a just-stopped server needs about a minute's wait - wait it out +rather than bypass it. + +After any change to the hold, boat or galaxy model, the test server needs a +**clean slate**: delete the `tradewinds_world*` folders and the `BoatHold`, +`TWPlayerData`, `TWIslandData` database folders. Stale records from a previous +model are indistinguishable from bugs. Say so when handing a build over. ## Project @@ -32,13 +40,38 @@ right tier, not to the end; `docs/TESTING-archive.md` is the old per-stage list, history only. Update PROGRESS and TESTING as features land. Load-bearing design rules (from the spec — breaking one is a bug): -trading transacts only against the **virtual hold** (DB-backed, sized by the -player's ONE boat — see `tradewinds-hold-plan.md`, normative); cargo leaves the -hold only by sale or destruction; everything downstream of the galaxy seed is a -pure function of (seed, position) with **no Bukkit imports** (package +trading transacts only against the **virtual hold**; cargo leaves the hold only +by sale or destruction; everything downstream of the galaxy seed is a pure +function of (seed, position) with **no Bukkit imports** (package `world.bentobox.tradewinds.galaxy`), unit-tested headlessly; no End world ever; interstice re-engage is always free; police mobs never drop loot. +## The boat/hold model (read before touching cargo or boats) + +`tradewinds-hold-plan.md` is **normative** here — it wins over the spec on +hold and boat mechanics. + +- **The hold belongs to the BOAT, not the player.** One `BoatHold` record + (material, cargo, fuel, expanders, owner, last-seen position, item TTL) is + identified by a PDC id (`tradewinds:boat-id`) stamped on **both** the boat + entity and its item form. `TWPlayerData` only points at `activeBoat` and + `oldBoat`. Cargo therefore exists exactly once, however the avatar travels. +- **Any new route that produces a boat must stamp it** (`BoatService.stamp`). + An unstamped hull has no lore and can never open its hold. This has bitten + three times: vanilla placement (`EntityPlaceEvent`), teleport pickup, and + crafting (the item lands on the **cursor**, not in a slot). `HoldGui` + self-heals one narrow case; do not rely on it. +- **Match boats by identity, never by material.** Two oak boats are not the + same boat. +- **Losing a boat is not abandoning one.** `HoldManager.setActiveBoat` demotes + the previous boat to an unowned OLD BOAT; `clearActiveBoat` just drops the + pointer. Taking a boat also strips its former owner — miss that and the + victim keeps phantom slots and the login path hands the boat back. +- **Money is whole coins.** Buy prices `ceil`, sell prices `floor` — the + direction stops the spread closing (nearest-rounding is exploitable). Never + format money by hand: `economy.Money.format(addon, amount)` asks the server's + Vault economy. + ## Environment (verified — do not "upgrade" blindly) - Paper API `26.2.build.40-alpha` (Java 25 bytecode: build with **JDK 25**, @@ -66,6 +99,24 @@ trap). Tests extend `world.bentobox.tradewinds.CommonTestSetup` (adapted from Gusher/AOneBlock). Never leave stale files in `target/test-classes` when renaming resources. +More traps, each of which cost a playtest: +- **`ItemStack` meta does not work** under this setup (the item factory is a + bare mock, so `getItemMeta()` returns null). To test PDC behaviour, mock the + ItemStack with a mocked `ItemMeta` and `PersistentDataContainer`. +- **Do not test against a hand-written stand-in for a manager.** `TestHolds` + drives the REAL `HoldManager` over an in-memory `Database` (see + `dataobjects/TestHoldManager`); an earlier fake "passed" rules the production + code did not implement, and five bugs shipped. +- The inherited `world` field in `CommonTestSetup` **shadows the `world.` + package root**, so fully-qualified `world.bentobox...` references fail to + compile inside those tests. Import the simple names. +- `PlayerInteractEvent` at AIR is *born cancelled*: never pair + `ignoreCancelled = true` with RIGHT_CLICK_AIR handling. Check + `useItemInHand() == DENY` instead. +- Bare `yes`/`no`/`on`/`off` are YAML 1.1 **booleans** — never use them as + locale keys. `ResourceYamlTest` fails the build on duplicate keys, because + Bukkit only warns and then silently drops one. + ## Reference repos (all local) - `~/git/bentobox` — BentoBox core source. diff --git a/README.md b/README.md index 9d8df0c..fcf38c0 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,84 @@ # TradeWinds A BentoBox game mode of sea trading, smuggling, and piracy: an endless -procedurally generated ocean dotted with NPC trading islands. Start with a boat -and a single trading bundle; get rich buying low and selling high — honestly, -or by smuggling contraband past customs, hunting bounties, or outright piracy. +procedurally generated ocean dotted with NPC trading islands. You start with a +rowing boat — **the boat is your cargo hold** — and get rich buying low and +selling high, honestly or otherwise: smuggling contraband past customs, +hunting bounties, or taking other sailors' ships. Inspired by TradeWars 2002 and Elite, rebuilt in Minecraft terms. -- Spec: [`TRADEWINDS_SPEC.md`](TRADEWINDS_SPEC.md) -- Progress: [`docs/PROGRESS.md`](docs/PROGRESS.md) -- Manual test checklists: [`TESTING.md`](TESTING.md) +> Status: in development. Playable end to end (trade, travel, crime, boats); +> player-owned islands are the remaining MVP stage. The data model is not +> stable yet — expect to wipe worlds and databases between builds. + +## How it plays + +**The ocean is one shareable seed.** Island positions, types, biomes, names, +security bands, tech levels and route costs are all pure functions of it, so +the same seed gives another server the same galaxy. Islands generate lazily as +players push outward, and the sea between them is dotted with wild islets — +free land to mine, farm and build on, with biome-appropriate ruins to find. + +**The boat is the hold.** A player has exactly one boat, and it sizes their +cargo: twenty ranks from a two-slot bamboo raft to the 21-slot Pale Oak Chest +Boat, then installable cargo expanders beyond that. The hold is virtual and +server-authoritative — no real container ever holds cargo, and cargo leaves it +only by being sold or destroyed. Fuel lives in its own slots, so fuel and +cargo never compete for the same space. + +**Trade reads the map.** Prices come from recipe-derived base values times the +island's type, tech level, security band and its own stock drift, so the best +routes are differentials you find by reading the chart. Margins are richest +where the law is thinnest. + +**Travel is a choice.** Rowing is free, slow and lawless. Warping is instant +but burns fuel and can fail, dropping you into the interstice — a hostile +Nether sea — with ghasts inbound and a free way back out. + +**Crime pays you into danger.** Contraband sells only at rougher ports. +Customs scan you on arrival; get caught and a patrol launches from the pier, +and you flee, fight or destroy the evidence. Reputation follows you: slip far +enough and police answer, your bounty hangs over your head, and safe markets +close to you. + +**Boats can be taken.** A hull left unattended in protected island space is +safe; anywhere else it is fair game, and whoever boards it gets the cargo too. +Lose yours and the chart remembers where you left it. + +## Documentation + +| Document | What it is | +|---|---| +| [`TRADEWINDS_SPEC.md`](TRADEWINDS_SPEC.md) | The authoritative spec — read first | +| [`tradewinds-hold-plan.md`](tradewinds-hold-plan.md) | Normative detail for the hold, boats and capture rules | +| [`tradewinds-design-decisions.md`](tradewinds-design-decisions.md) | What was decided, why, and what is still open | +| [`tradewinds-dev-plan.md`](tradewinds-dev-plan.md) | The stage plan | +| [`tradewinds-overview.md`](tradewinds-overview.md) | The short pitch, for server admins | +| [`docs/PROGRESS.md`](docs/PROGRESS.md) | What is done, and every pitfall hit on the way | +| [`TESTING.md`](TESTING.md) | Manual test plan, ordered by risk | ## Building ```bash -mvn clean package # requires JDK 25; jar lands in target/ +mvn clean package # jar lands in target/ +mvn test # 250 headless tests ``` -Requires BentoBox 3.18.1+ on Paper 26.2. Drop the jar in -`plugins/BentoBox/addons/`. +Build with **JDK 25** (Paper 26.2 ships Java 25 bytecode); the project +compiles at release 21. Requires BentoBox 3.18.1+ on Paper 26.2. + +Everything downstream of the galaxy seed lives in +`world.bentobox.tradewinds.galaxy` with **no Bukkit imports**, so the world +generation and pricing maths are unit-tested headlessly. + +## Installing + +Drop the jar in `plugins/BentoBox/addons/` and restart. Vault is required for +the economy; PlaceholderAPI and the Border addon are optional. TradeWinds +coexists with other BentoBox game modes. + +Nearly every number is a config knob: island spacing and density, tech and +band effects, fuel and warp costs, boat ranks and prices, scan chances, police +response, reputation thresholds. The contraband and crime layers can be turned +off entirely for family-friendly servers. From fd0b45889b1efdd26498c56d362915fbacc378f6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 17:59:09 -0700 Subject: [PATCH 076/112] Record the salvage economy and the requisition-board mission pattern Design notes only, no code. Two things decided in discussion on 2026-08-03: The salvage market: ports buy almost anything but shallowly, reusing the stock drift already in IslandDataManager, with a value-weighted purse rather than a unit count (ten diamonds should move a port more than ten wheat), a separate bucket so junk cannot crater the legitimate price gradient, and NBT preserved because a worn bow and a mint one are not the same good. That last part narrows the "cargo leaves only by sale or destruction" rule to trader-bought cargo, so player-loaded salvage can be withdrawn - written down because it touches a load-bearing rule. And Ben's requisition idea, which is the shape the whole missions system should take: the island posts the order, so there is no escrow, no player order book and no warehouse to contradict the boat-is-the-hold rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- tradewinds-design-decisions.md | 59 ++++++++++++++++++++++++++++++++++ tradewinds-dev-plan.md | 6 +++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/tradewinds-design-decisions.md b/tradewinds-design-decisions.md index 18afe19..1662500 100644 --- a/tradewinds-design-decisions.md +++ b/tradewinds-design-decisions.md @@ -46,6 +46,48 @@ These are the reasons the design works. If a future change breaks one of these, - Everything in Minecraft is tradeable. **BlueBook** (`~/git/bluebook`) supplies base prices; modifiers from island type, biome, security band, and per-island stock/price drift persisted via the BentoBox database. Players may sell found/crafted items through the same market. - Margins scale with danger: the best prices live in the risky bands. +### Salvage & free trading (decided 2026-08-03) +The scavenger/farmer/pirate has loot and nowhere to put it. Ports buy almost +anything, but **shallowly**: price decays as you sell in, recovers over time, so +a stack of diamonds is several ports' worth of business rather than one payday. +This is the existing stock-drift machinery (`IslandDataManager`, `TWIslandData`, +`driftScale`/`stockDecayPerHour`) applied to found goods — the rate limit is +what stops an infinite farm faucet outcompeting the trade game. +- **Drift must be value-weighted, not unit-weighted.** Ten diamonds should move + a port's prices far more than ten wheat; `adjustStock` currently counts units. + Model it as the trader's **purse** — coins spent on a category, replenishing — + which is also how players naturally describe it. +- **Salvage gets its own stock bucket** (not `MISC`) and a steeper discount than + proper trade goods. Otherwise dumping junk craters the legitimate price + gradient in that category, which is a griefing vector on a small server, and + piracy out-earns honest trading. +- **Tech level gates what a port recognises**: a TL1 hamlet has no use for + netherite. Loot therefore has a *destination*, which is a route. +- **NBT is preserved and priced.** A worn bow, a Potion of Strength II and a + Silk Touch pick are not their base materials. `PriceEngine` already applies + durability and can weigh enchantments (`getEnchantmentValue`, currently + unwired); potions need `PotionMeta` handling. The hold (`Map`) + and the `Material`-typed `MarketService` signatures are what throw NBT away — + both must carry ItemStacks. Breaking data-model change: wipe on deploy. +- **The hold becomes two-way for player-loaded goods.** This narrows the + load-bearing "cargo leaves only by sale or destruction" rule, which now + applies to **trader-bought cargo only** — that is what preserves cargo + commitment and keeps the hold from being an off-market transfer channel. + Player-loaded salvage can be withdrawn. Track provenance per stack and never + merge across it, or the flag becomes ambiguous. +- **Sold goods resurface for players to buy**, but only notable items (enchanted, + named, above a value threshold), a slot or two per port, with a TTL — and they + surface at a *different* island than the one they were sold at ("the trader + shipped it on"). Unpredictable destination is what stops alt-account + laundering turning port shelves into a global auction house. +- Price discovery is a **logbook**, not an oracle: prices cannot be shown for + ports you have not visited, and the chart remembers only what you saw and how + long ago (Elite/TradeWars precedent). At the counter, show the live price *and + the depth* — "this port will take ~40 more before the price falls" — which is + the number the player actually needs. Purchasable market reports at high-tech + ports, radius and freshness scaling with tech level, give TL a role beyond + goods; plaza rumours are the cheap flavour version. + ### Contraband & customs - Master **config gate disables all illegal-goods mechanics** for family servers; on by default. - Contraband: **sugar** (never referred to as drugs, in code, config, or locale). Villager "passengers" in boats sellable at less-safe islands only (how the player boats a villager is their problem — emergent by design). @@ -69,6 +111,23 @@ These are the reasons the design works. If a future change breaks one of these, - Interstice is a **shared** world (enabling warp-space interdiction/ambush — very Elite) rather than instanced pockets. - Post-MVP: stall rental on NPC islands for the player market (goods join the island's trade pool; rent as sink; stall location matters via the route graph); insurance (hull/expanders insurable, cargo never); "last seen" wanted-player intel sold at trade posts; Elder Guardian as the top-tier wanted response (its fatigue aura hits innocents — `EntityPotionEffectEvent` cancellation is possible but fights the mob's design, hence demoted from standard police). +- **Missions as NPC requisitions** (Ben, 2026-08-03 — the intended shape for the + whole missions system, not just delivery jobs): a port posts a standing order — + *"Baker's Reach wants 200 wheat at 40/unit, expires in 3 days"* — seeded from + the island's type, tech level and current stock, on a board at the plaza. The + player fills it in whole or in part, at a price fixed above the drifting market + rate, and the reward is the premium plus reputation. + This is deliberately the **5% of an EVE order book that fits a 20-player + server**: it gives the farmer a goal and the scavenger a destination, and it + needs no escrow, no player-placed orders and no offline value transfer — the + counterparty is the island. Player-placed buy orders were considered and + rejected for now (an order book with three participants is a waiting room, not + a market, and "collect your goods from the trader" implies a warehouse, which + breaks the boat-is-the-hold rule). Revisit only if a server ever has the + population to support it, and then at spawn only. + Generalises to every mission type: bounty contracts, passenger runs, courier + jobs and salvage recovery are all "a board posts a job, you sail, you're paid." + ## Open questions 1. **Name — RESOLVED: TradeWinds** (one word, house style like BSkyBlock/AcidIsland; repo `TradeWinds` under BentoBoxWorld). "Trade Wars MC" rejected — the TradeWars 2002 mark is still live, and "Wars" mis-signals a trading game; Sandlot's Tradewinds series is defunct and the phrase is generic. 2. **Poseidon internals:** can a radial positional mask multiply into its existing density tweaks, or is it cleaner to lift its sea-level/decoration handling into a fresh generator with the mask native? (First real Stage 1 task.) diff --git a/tradewinds-dev-plan.md b/tradewinds-dev-plan.md index 3cc7876..6cb1355 100644 --- a/tradewinds-dev-plan.md +++ b/tradewinds-dev-plan.md @@ -87,7 +87,11 @@ Each stage produces a testable, playable increment. Stages are sized so a stage - Player market stalls rentable on NPC islands (goods enter that island's trade pool; rent as money sink). - Chart data as a purchasable good; wanted-player "last seen" intel for bounty hunters. - Boat/expander insurance (cargo never insurable); boat destruction and death-drop rules tuning. -- Delivery contracts/missions for positive reputation. +- **Missions, as NPC requisition boards** — "Baker's Reach wants 200 wheat at + 40/unit, 3 days", posted at the plaza, seeded from island type/tech/stock, paid + above market plus reputation. This is the intended pattern for *all* mission + types (delivery, bounty, passenger, salvage): the island is the counterparty, + so no escrow or player order book is needed. See the design-decisions entry. - Multiple galaxies as additional worlds; inter-galaxy warp as endgame. - Elder Guardian boss response for the most-wanted tier. From 37b69b4fe039c45f71f3f052fba1c93b7e6b12a7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:08:01 -0700 Subject: [PATCH 077/112] Plan the salvage economy as Stage 7.5 tradewinds-salvage-plan.md, normative for the salvage market, the NBT hold and price discovery, in seven phases. The order is deliberate: the trader's purse (value-weighted drift) and price coverage come first because they are small, independent of the refactor, and they are where the feel of a shallow market gets tuned - worth settling before the hold rework rather than after. Findings that shaped it, from reading the code rather than assuming: MarketService already sells anything the engine can price, so the blocker is PriceEngine coverage, not market design; PriceEngine already applies durability and already has an unwired getEnchantmentValue(), so NBT valuation is mostly written and simply unreachable; BentoBox's ItemStackTypeAdapter is registered in its Gson factory, so List persists with full meta for free (mind its 99-amount clamp - one stack per slot never trips it); and HoldService is effectively the only consumer of BoatHold's maps, with adjustStock having just two call sites, so the refactor is concentrated rather than spread. Records the testing risk honestly: ItemStack meta does not work under this MockBukkit setup, so slot-packing arithmetic stays in a pure Bukkit-free helper and serialisation is covered manually. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 5 +- README.md | 1 + tradewinds-dev-plan.md | 7 ++ tradewinds-salvage-plan.md | 201 +++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 tradewinds-salvage-plan.md diff --git a/CLAUDE.md b/CLAUDE.md index 862c644..fcf1f51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,10 @@ interstice re-engage is always free; police mobs never drop loot. ## The boat/hold model (read before touching cargo or boats) `tradewinds-hold-plan.md` is **normative** here — it wins over the spec on -hold and boat mechanics. +hold and boat mechanics. `tradewinds-salvage-plan.md` is normative for the +salvage economy, the NBT-aware hold and price discovery — read it before +touching pricing, drift or hold contents, because it deliberately narrows the +one-way-cargo rule below to trader-bought cargo only. - **The hold belongs to the BOAT, not the player.** One `BoatHold` record (material, cargo, fuel, expanders, owner, last-seen position, item TTL) is diff --git a/README.md b/README.md index fcf38c0..72a1958 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Lose yours and the chart remembers where you left it. |---|---| | [`TRADEWINDS_SPEC.md`](TRADEWINDS_SPEC.md) | The authoritative spec — read first | | [`tradewinds-hold-plan.md`](tradewinds-hold-plan.md) | Normative detail for the hold, boats and capture rules | +| [`tradewinds-salvage-plan.md`](tradewinds-salvage-plan.md) | Normative plan for the salvage economy, NBT hold and price discovery | | [`tradewinds-design-decisions.md`](tradewinds-design-decisions.md) | What was decided, why, and what is still open | | [`tradewinds-dev-plan.md`](tradewinds-dev-plan.md) | The stage plan | | [`tradewinds-overview.md`](tradewinds-overview.md) | The short pitch, for server admins | diff --git a/tradewinds-dev-plan.md b/tradewinds-dev-plan.md index 6cb1355..6009c23 100644 --- a/tradewinds-dev-plan.md +++ b/tradewinds-dev-plan.md @@ -83,6 +83,13 @@ Each stage produces a testable, playable increment. Stages are sized so a stage **Accept:** purchased islands appear in open ocean, never overlapping any range; AcidIsland-equivalent island life works. +## Stage 7.5 — Salvage economy & free trading +Scavengers, farmers and pirates need a market; the hold needs to carry NBT and +work in both directions. Planned in full in **`tradewinds-salvage-plan.md`** +(normative): trader's purse (value-weighted drift) → price coverage + salvage +bucket → tech gating → NBT hold → two-way hold → price discovery (depth at the +counter, chart logbook, market reports) → resale shelves. + ## Stage 8 — Post-MVP backlog (seeded, unscheduled) - Player market stalls rentable on NPC islands (goods enter that island's trade pool; rent as money sink). - Chart data as a purchasable good; wanted-player "last seen" intel for bounty hunters. diff --git a/tradewinds-salvage-plan.md b/tradewinds-salvage-plan.md new file mode 100644 index 0000000..9d42bb8 --- /dev/null +++ b/tradewinds-salvage-plan.md @@ -0,0 +1,201 @@ +# TradeWinds — Salvage & Free Trading Plan + +**Normative** for the salvage economy, the NBT-aware hold, and price discovery. +Where this document and `TRADEWINDS_SPEC.md` disagree on those subjects, this +one wins. It sits alongside `tradewinds-hold-plan.md`, which remains normative +for boat identity, ownership and capture. + +## The problem + +A player scavenging islets, farming, or taking prizes at sea accumulates goods +with nowhere to go. Today the hold will accept them, and `MarketService.sell()` +will sell anything the price engine can quote — there is no whitelist — but: + +1. `PriceEngine` cannot price mob loot at all (no crafting recipe, not in the + 80-entry config table), and correctly refuses ingot↔block cycles. Unpriceable + goods have no sale route, so destruction is the only exit. +2. NBT is discarded on the way in. The hold is `Map`, so an + enchanted sword becomes `DIAMOND_SWORD` and a nearly-broken pick sells for + the price of a mint one. `MarketService.basePrice(Material)` narrows to a + material before the engine — which already takes an `ItemStack` — sees it. +3. Cargo is one-way, so the hold cannot be used as a hold while mining an islet. +4. Stock drift counts **units**, so ten diamonds and ten wheat hit a port + identically. A stack of diamonds should be several ports' worth of business. + +## Shape of the answer + +Ports buy almost anything, but **shallowly**: the price decays as you sell in and +recovers over time. That rate limit is what stops an infinite farm from +outcompeting the trade game — the answer to "I have 2,000 cobblestone" becomes +"visit five ports", which is travel, which burns fuel and meets customs. + +The game is not live anywhere, so no migration or compatibility work is in +scope. Wipe worlds and databases freely. + +--- + +## Phase 1 — The trader's purse (value-weighted drift) + +Drift becomes coin-denominated: a port has spent *money* on a category, not +received *items*. This is the model players already describe out loud, and every +number tuned later depends on it, so it comes first. + +- `IslandDataManager.adjustStock(spec, category, units)` → takes the transaction + **value**. The two call sites are `MarketService.sell()` and `buy()`. +- `economy.drift-scale` (500 units) → coin-denominated and renamed; + `economy.stock-decay-per-hour` (50) → coins per hour. Retune both: a + full boatload of cheap bulk should approach the `drift-min` 0.7 floor, and + recovery should be hours, not minutes. +- `decayed()` and `driftFactor()` are already pure and unit-tested — extend the + tests rather than adding new machinery. + +**Acceptance:** selling 10 diamonds visibly moves that port's prices; selling 10 +wheat barely does. Same coin value moves them the same amount. + +## Phase 2 — Price coverage, and a salvage bucket + +- **Audit first.** A headless test enumerating every `Material` through + `PriceEngine`, reporting priceable vs not. This produces the actual gap list + instead of a guess; check the report into `docs/`. +- Add config base prices for what cannot be derived — mob drops (bone, string, + gunpowder, leather, ender pearls, blaze rods…) and raw drops whose only + "recipe" is a block cycle. +- **Salvage is anything that is not a recognised trade good in the game** — that + is, a material appearing on no island type's produce or demand list. Salvage + sales drift against a single per-island `SALVAGE` purse and never touch the + trade categories, so dumping junk cannot crater the legitimate price gradient + (a griefing vector on a small server). Existing trade goods keep their present + categories and behaviour exactly. +- New knob `economy.salvage-discount` (~0.35–0.4 of book). Salvage must pay + worse than proper cargo, or piracy and farming out-earn honest trading. + +**Acceptance:** every material either has a price or is on a short, deliberate +refusal list. Selling 2,000 cobble at a mining port does not move the price of +its iron. + +## Phase 3 — Tech gates what a port will handle + +A TL1 fishing hamlet has no use for netherite. This gives loot a *destination*, +which is a route, which is gameplay. + +Implement by value rather than a per-material tier table: +`economy.max-item-value-per-tech-level × techLevel` is the most a port will pay +for a single item; above that it declines. One knob, no table to maintain, and it +scales automatically as prices are retuned. + +**Acceptance:** an enchanted netherite sword is refused at a TL1 port and bought +at a TL7 one, with a locale message that says why. + +## Phase 4 — The NBT hold + +The significant refactor. `PriceEngine` already applies durability and already +has `getEnchantmentValue()` (written, documented, and never called) — so most of +the valuation exists and is simply unreachable. + +- `BoatHold.contents`: `Map` → `List`, **one entry + per slot**, exactly like a real inventory. `expanders`: + `List>` → `List>`. **Fuel stays + material-keyed** — fuel value is per material and genuinely fungible. +- Persistence is free: BentoBox's `ItemStackTypeAdapter` is registered in + `BentoboxTypeAdapterFactory` and round-trips full meta through Bukkit's YAML + serializer. Note its `MAX_AMOUNT` 99 clamp — one stack per slot never trips it. +- Slot accounting gets *simpler and exact*: list size is slots used, and unique + items each occupy a slot, which is what players expect. +- `HoldService`: the (Material, amount) API becomes ItemStack-based. Keep a + Material convenience overload for the buy path and fuel. +- `MarketService.basePrice` / `playerSellsAt` / `playerBuysAt` / `sell` take + ItemStacks. Wire `getEnchantmentValue()` in behind a config factor. +- Add `PotionMeta` handling to `PriceEngine`: a water bottle and a Potion of + Strength II are both `POTION` today. +- `HoldGui` gets simpler — it already renders from a `stacks` list. + +**Testing risk, and the mitigation:** under this MockBukkit setup `ItemStack` +meta does not work (the item factory is a bare mock, `getItemMeta()` returns +null), so NBT behaviour needs mocked ItemStacks with mocked `ItemMeta`, and the +YAML round-trip cannot be tested headlessly at all. Therefore **keep the +slot-packing arithmetic in a pure helper** that operates on counts and stack +limits with no Bukkit types, unit-test that, and cover serialisation in +`TESTING.md` as a manual check. + +**Acceptance:** a worn bow sells for less than a mint one; a Silk Touch pick +sells above a plain one; a Potion of Strength II is not priced as a water +bottle; a hold full of distinct enchanted gear survives a server restart. + +## Phase 5 — The hold becomes two-way + +This **narrows a load-bearing rule**: "cargo leaves the hold only by sale or +destruction" now applies to **trader-bought cargo only**. That is what preserves +cargo commitment (speculate and you must find a buyer) and keeps the hold from +being an off-market player-to-player transfer channel. Player-loaded salvage may +be withdrawn. + +- Mark bought cargo with a PDC key (`tradewinds:cargo`) on the stack itself. It + survives serialisation, travels with the goods, and stays visible if a bug ever + leaks one into a player inventory. +- **Never merge stacks across provenance**, or one bought diamond locks up the + twenty you mined. Separate list entries; the list-of-stacks model makes this + natural. +- `HoldGui` becomes interactive: click and shift-click to move goods between + player inventory and hold, refusing marked stacks outbound, and refusing + containers/vessels inbound as `HoldService.refuses()` already does. + +**Acceptance:** mined cobble goes in and comes back out; bought cargo cannot be +withdrawn and says so; capacity is never exceeded; nothing duplicates across a +restart or a boat swap. + +## Phase 6 — Price discovery + +Two distinct questions, and only one is hard. + +- **At the counter you know everything.** Show the live unit price *and the + depth*: "this port will take about 40 more before the price falls", derived + from purse headroom. This is the number the player actually needs — it tells + them when to stop selling and leave. +- **Across the map you should not know.** A **logbook**: extend + `TWPlayerData.chartedIslands` (already a `Set`) to remember prices you + have personally seen, per island, with a timestamp, and render them on the + chart with their age. Elite and TradeWars precedent. Stale data being visibly + stale is a feature; it makes a veteran trader *skilled* rather than merely + rich. +- **Market reports** purchasable at high-tech ports, radius and freshness scaling + with tech level — gives TL a role beyond what is on the shelf, and a reason to + visit an industrial hub you are not trading with. +- **Plaza rumours** as the cheap flavour version: "they're paying well for metals + two jumps north." + +**Acceptance:** no price is visible for a port never visited; remembered prices +show their age; the sell page states the depth remaining. + +## Phase 7 — Resale shelves + +Goods sold to a port can resurface for other players to buy — the charm is +"someone dumped a Fortune III pick at Baker's Reach". + +- **Notable items only**: enchanted, renamed, or above a value threshold. A slot + or two per island, with a TTL. +- The item surfaces at a **different island** than the one it was sold at — the + trader shipped it on. Unpredictable destination is what keeps port shelves from + becoming an alt-account laundering channel or a global auction house. +- `TWIslandData` grows a `List` with timestamps; bounded and swept. + +**Acceptance:** a distinctive item sold at one port is findable at another and +not at the one it was sold to; shelves do not grow without bound. + +--- + +## Out of scope, sequenced after + +**Missions as NPC requisition boards** — see `tradewinds-design-decisions.md` and +Stage 8 of `tradewinds-dev-plan.md`. Phase 6's price discovery is the *pull* +side's natural companion, so missions come after it. + +## Working order + +Phases 1–3 are small, independent of the refactor, and playtestable immediately — +they are also where the *feel* of a shallow market gets tuned, which is worth +settling before committing to the hold rework. Phases 4 and 5 land together (a +two-way hold built on material counts would only need redoing). Then 6, then 7. + +Every gameplay number goes in config from the phase that introduces it, all +player-facing text in the locale, and `docs/PROGRESS.md` and `TESTING.md` get +updated as each phase lands. From 6a828fd6c36b43f5a7714e56fe7af67a0260e4d1 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:15:40 -0700 Subject: [PATCH 078/112] Value drift by money rather than volume (Stage 7.5 Phase 1) Stock drift counted items, so ten diamonds and ten wheat were the same business to a port and a stack of diamonds was one payday instead of several ports' work. Stock is now coins of inventory position - a port's capacity is its capacity to spend - which is both the realistic model and the one players describe out loud when they talk about a trader's purse. TWIslandData.stock becomes stockValue and adjustStock becomes adjustStockValue(coinDelta), fed the transaction total from the two call sites in MarketService. The two config knobs are renamed rather than retuned in place so that nobody has to guess whether the number means crates or coins: drift-value-scale 30000 and stock-decay-value-per-hour 3000. Shipped with absorbableValue(), which Phase 6 needs for the depth reading at the counter, because the drift clamp is what defines it - only the first (1 - driftMin) x scale coins move a price, making saturation 9000 coins and recovery about three hours. Its arithmetic lives in pure package-visible statics next to the existing decayed(), since IslandDataManager builds a real Database in its constructor and does not mock cleanly. Tuning: 7 diamonds, 84 iron ingots, 391 wheat or 3000 cobblestone saturate one port. Value-dense cargo is what forces the tour. 254 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 5 + docs/PROGRESS.md | 29 ++++ files.zip | Bin 0 -> 13665 bytes files/tradewinds-design-decisions.md | 82 +++++++++++ files/tradewinds-hold-plan.md | 132 ++++++++++++++++++ files/tradewinds-overview.md | 33 +++++ .../world/bentobox/tradewinds/Settings.java | 27 ++-- .../dataobjects/IslandDataManager.java | 61 ++++++-- .../tradewinds/dataobjects/TWIslandData.java | 19 ++- .../tradewinds/economy/MarketService.java | 13 +- .../tradewinds/economy/PriceModel.java | 28 ++-- src/main/resources/config.yml | 11 +- .../dataobjects/IslandDataManagerTest.java | 25 +++- .../tradewinds/economy/PriceModelTest.java | 30 +++- 14 files changed, 448 insertions(+), 47 deletions(-) create mode 100644 files.zip create mode 100644 files/tradewinds-design-decisions.md create mode 100644 files/tradewinds-hold-plan.md create mode 100644 files/tradewinds-overview.md diff --git a/TESTING.md b/TESTING.md index 72c839b..e335bb1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,6 +70,11 @@ noise. - [ ] Warp: you and the boat arrive facing the **dock**; hold forward and you reach it. - [ ] Sell the cargo at the new island at a different price than you paid. +- [ ] **Selling in saturates a port by value, not volume.** Sell ~7 diamonds (or + 12 emeralds) at one island: the unit price should fall to the floor and + stop falling. Selling a few hundred wheat should move it about as much as + those 7 diamonds did, and a boatload of cobblestone barely at all. + Prices recover after ~3 hours of real time. - [ ] `/tw chart` raises holograms: island names and a DOCK marker, and no BOAT marker while you are sitting in the boat. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5ce87d4..2dd2f80 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,35 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 7.5 Phase 1 — the trader's purse (2026-08-03) + +Planned in `tradewinds-salvage-plan.md`. Stock drift was counted in **items**, +so ten diamonds and ten wheat hit a port identically and a stack of diamonds +was one payday rather than several ports' business. Drift is now denominated in +**coins of inventory position** - a port's capacity is its capacity to *spend*. + +- `TWIslandData.stock` -> `stockValue`; `IslandDataManager.adjustStock` -> + `adjustStockValue(spec, category, coinDelta)`, fed the transaction total from + `MarketService.sell`/`buy` (its only two call sites). +- `economy.drift-scale` 500 -> `economy.drift-value-scale` **30000**; + `economy.stock-decay-per-hour` 50 -> `economy.stock-decay-value-per-hour` + **3000**. Renamed rather than retuned in place so nobody has to guess the unit. +- New `IslandDataManager.absorbableValue()` - headroom before the price floor. + This is Phase 6's "depth at the counter" number, built now because the drift + clamp is what defines it: only the first `(1 - driftMin) x scale` coins move a + price at all, so saturation is 9000 coins and recovery about 3 hours. + +**Tuning check** (demanded good, SAFE band, no drift): 7 diamonds, 12 emeralds, +84 iron ingots, 391 wheat or 3000 cobblestone saturate one port. That puts a +stack of diamonds at roughly nine ports' worth of business and a boatload of +cobble at about half a port's - which is the intended shape: value-dense cargo +is what forces the tour, and bulk junk was never going to pay well anyway. + +**Pitfall avoided:** the saturation/headroom math is in pure package-visible +statics (`saturationValue`, `absorbable`) beside the existing `decayed`, because +`IslandDataManager`'s constructor builds a real `Database` and is awkward to +mock. Same reason `decayed` was already shaped that way. + ## Whole coins, and the economy's own formatter (2026-08-03) "Oak Log x1 - $0.97" made every market chart ragged. Two independent faults: diff --git a/files.zip b/files.zip new file mode 100644 index 0000000000000000000000000000000000000000..090338c1ef998ae073d8873ec3566ed6b56ee1d4 GIT binary patch literal 13665 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 0 (clean players show nothing); PlaceholderAPI placeholder exposed so nametag plugins (TAB etc.) can render it themselves instead of conflicting. Note: sneaking hides nameplates on foot, but players in boats cannot sneak — traveling pirates are always visible. +- Police mobs by mobility, not just element: **Phantoms pursue** (fast; combustion cancelled or day patrols burn at dawn), **Guardians hold the sea perimeter** (slow swimmers who will never catch a boat — they are area denial around the island, not chasers), **Golems** handle anyone who lands. All PDC-tagged, ticking `setTarget` reassertion, **zero drops** (or wanted players become a crime-powered iron farm). Pursue within the protection zone, break off when the target crosses the visible border (escape you can see), despawn beyond island range or on unload. +- Per-band knobs: scan chance **and** response size — a mid-band island may scan rarely but respond hard. Route planning with contraband should be map-reading skill. +- Anti-bait guard for innocent-kill penalties: direct kills only; prior damage from the victim exempts. Log edge cases, tune later; do not over-engineer pre-MVP. + +## Proposed but not yet adopted (needs Ben's sign-off) +- Concrete reputation numbers: −1000..+1000 scale; hit villager −5, kill villager −25, kill police −15, kill innocent −100, scan-caught −30 + fine, slave sale −20; decay ~+1 per 15 min clean active play; band thresholds +250 / 0 / −200 / −500. +- **Regional density = civilization**: occupied-position density higher near spawn, thinning outward, with security band correlated to local density (clusters civilized, isolated islands anarchic). Makes the map itself communicate risk. +- Wanted players suffer a higher warp-failure chance (crime feeds the interstice ambush meta). +- Interstice is a **shared** world (enabling warp-space interdiction/ambush — very Elite) rather than instanced pockets. +- Post-MVP: stall rental on NPC islands for the player market (goods join the island's trade pool; rent as sink; stall location matters via the route graph); insurance (hull/expanders insurable, cargo never); "last seen" wanted-player intel sold at trade posts; Elder Guardian as the top-tier wanted response (its fatigue aura hits innocents — `EntityPotionEffectEvent` cancellation is possible but fights the mob's design, hence demoted from standard police). + +## Open questions +1. **Name — RESOLVED: TradeWinds** (one word, house style like BSkyBlock/AcidIsland; repo `TradeWinds` under BentoBoxWorld). "Trade Wars MC" rejected — the TradeWars 2002 mark is still live, and "Wars" mis-signals a trading game; Sandlot's Tradewinds series is defunct and the phrase is generic. +2. **Poseidon internals:** can a radial positional mask multiply into its existing density tweaks, or is it cleaner to lift its sea-level/decoration handling into a fresh generator with the mask native? (First real Stage 1 task.) +3. **Dock placement:** deterministic terraformed shelf at a fixed bearing (predictable, teachable) vs. jigsaw terrain-adaptive placement (organic) — current lean: deterministic dock, adaptive decorations. +4. **Border interaction:** "click the wall" needs packet-level handling of client-side blocks; proximity trigger + action-bar prompt is cheaper and reads the same. Pick one; Border must run in passable/visual mode regardless. +5. **Boat loss rules:** what drops on boat destruction / player death? Full expander drop makes trader-piracy the apex game (very EVE) but is brutal; insurance is the counterweight. Undecided. +6. **Interstice PvP rules** if shared: can hunters lawfully take non-wanted players there, or is interdiction itself a rep crime? +7. **Scan/flag tuning:** scan cooldown length, flee-flag duration, "caught" proximity radius. +8. **Galaxies architecture:** multiple configured addon instances (AcidIsland/BSkyBlock coexistence model) vs. a world manager inside one addon. Deferred, but keep the world reference abstracted from day one. +9. **Villager shoreline safety:** golems can't path onto water; keep villagers inland behind the docks so drive-by boat raids don't trivialize the police, or add a ranged deterrent. +10. **Reputation numeric tuning** (see proposal above) and whether reputation is global or per-galaxy once galaxies exist. diff --git a/files/tradewinds-hold-plan.md b/files/tradewinds-hold-plan.md new file mode 100644 index 0000000..2aaacc3 --- /dev/null +++ b/files/tradewinds-hold-plan.md @@ -0,0 +1,132 @@ +# TradeWinds — Virtual Hold & Boat Mechanics: Implementation Plan +*For Claude Code. Merged from Ben's "# Boat mechanics.md" (authoritative on conflicts) and the earlier virtual-hold plan. Server-authoritative holds in the database; no real containers ever hold cargo. Clean-slate build, no migration.* + +## Core model + +**One Boat Rule: a player has exactly one boat, or none.** The hold is conceptually the *player's* cargo inventory; the boat they possess only sets its size. Upgrading never moves items — the slot count grows and existing contents stay put. + +Storage is database-backed (BentoBox Database API); the boat item/entity is a key. While a player owns a boat, the hold is keyed to the player; when the boat becomes a dropped item, the hold contents travel with the item (item-keyed record with the hold UUID in the ItemStack PDC) until picked up or expired. + +## Boat ranks (defaults; `config.yml` map `boat-material → slots`) + +| Rank | Boat | Slots | | Rank | Boat | Slots | +|---|---|---|---|---|---|---| +| 1 | Bamboo Raft | 2 | | 11 | Dark Oak Boat | 12 | +| 2 | Oak Boat | 3 | | 12 | Jungle Chest Boat | 13 | +| 3 | Spruce Boat | 4 | | 13 | Mangrove Boat | 14 | +| 4 | Bamboo Chest Raft | 5 | | 14 | Acacia Chest Boat | 15 | +| 5 | Birch Boat | 6 | | 15 | Cherry Boat | 16 | +| 6 | Oak Chest Boat | 7 | | 16 | Dark Oak Chest Boat | 17 | +| 7 | Jungle Boat | 8 | | 17 | Pale Oak Boat | 18 | +| 8 | Spruce Chest Boat | 9 | | 18 | Mangrove Chest Boat | 19 | +| 9 | Acacia Boat | 10 | | 19 | Cherry Chest Boat | 20 | +| 10 | Birch Chest Boat | 11 | | 20 | Pale Oak Chest Boat | 21 | + +## Expanders + +- Install **only in a Pale Oak Chest Boat** (the top rank). An installed expander **occupies one C slot**; clicking it opens a nested inventory panel — same style, own TNT destroy slot, **no fuel row**. +- Proposed defaults *(veto welcome)*: nested inventory is **21 slots** (net +20 per expander); expanders cannot contain other expanders or any container item; an expander can be dragged to TNT **only when empty**; nested inventories are openable only while the expander sits in a Pale Oak Chest Boat's hold (an expander that ends up elsewhere via drop/pickup rides along as inert cargo, contents preserved, until installed again or destroyed); sold at shops only, and only at **top-tech (TL7) islands** — the endgame sink has a home port. +- No installed-count cap needed: the 21 C slots are the natural ceiling, and doubling prices (below) make the tail aspirational. A fully expanded ship approaches ~440 slots and a nine-figure spend — the "guard it with your life" endgame. + +## Pricing (defaults; formulas in config) + +- **Boats — quadratic:** `price = 25 × slots²`. Anchors: Bamboo Raft 100, Birch 900, Jungle 1,600, Mangrove 4,900, Pale Oak 8,100, Cherry Chest 10,000, Pale Oak Chest 11,025. Early upgrades are pocket change; the top rungs cost real trading profit but stay reachable — correct, since boats are also craftable and the shop is a convenience for players who haven't found the rarer groves. +- **Expanders — doubling:** `price = 5,000 × 2^(installed)`. First 5,000; fifth 80,000; eighth 640,000; tenth 2.56M. The curve *is* the endgame money sink — no cap required, the wallet is the cap. + +## Hold GUI (BentoBox Panel API) + +Six rows: + +``` +X T X X X X X X X +X C C C C C C C X +X C C C C C C C X +X C C C C C C C X +X X X X X X X X X +X F F F F F F F X +``` + +- **T — TNT destroy slot.** Lore: "Drag cargo here to destroy them." Dragging cargo onto it removes the items from the game (destroyed, NOT dropped into the world). Fuel dragged here is never destroyed. +- **C — cargo slots**, up to 21, unlocked left-to-right/top-down to the boat's capacity; locked slots render as border panes (X). +- **F — fuel slots**, 7, shown as pale-red glass panes with lore "Fuel Slot" when empty. Fuel dropped anywhere in the GUI routes to a fuel slot and stacks where possible. Fuel is freely added, moved, and **removed** — fuel does not compete with cargo and is not subject to one-way rules. Lava buckets occupy a fuel slot each; on consumption the lava is burned and the empty bucket returned. +- **X — light-blue glass border**, no lore. + +**Contents auto-consolidate:** items are always stored in optimal stacks, never spread — slot usage is always minimal. + +### Removal rules +Cargo can leave the hold only by being **sold** or **destroyed** (TNT slot). Transfers out to player inventory, drops, hotkeys, shift-clicks, and hopper-adjacent tricks all must fail (adversarial test list required). + +### Insertion rules +**Any item may be added to the hold, one-way**, except pouches, bundles, shulker boxes, or any other container item (they would expand effective hold size). Sole exception: official Cargo Expander items, in Pale Oak Chest Boats only. If an island won't buy what you inserted, it simply wastes space until you destroy it. Customs stamping is **removed entirely** — capacity and the stock/demand pools carry the balance. + +## Opening the hold +- Sitting in the boat + right-click — chest boat or not. +- Sneak + right-click the boat entity. +- Right-click the boat item in your inventory. + +## Obtaining and upgrading boats + +Three routes: **shop**, **craft**, **pick up a dropped boat item**. + +### Shop +- Shops list only boats **bigger** than your current boat, priced per the quadratic formula above, and only up to the island's **tech level**: ranks sold ≤ TL × 3 on a 1–7 scale (config), so top boats exist only at high-tech ports. Tech gates shops only — never docking, riding, or crafting. +- Purchase replaces your current boat — **the replaced boat is destroyed**; you are buying an upgrade, not a second boat. (Grants a boat if you had none.) Contents persist untouched — capacity simply increases. Example: Pale Oak Boat (18 slots, full of granite) upgraded to Cherry Chest Boat (20) → same 18 stacks of granite, 2 new empty slots. + +### Crafting +- Same replacement semantics. Crafting a boat **smaller** than your current boat is blocked (craft event cancelled with a locale message). +- A player may add a chest to their current boat at any time (vanilla boat+chest recipe) — an in-place upgrade to the chest variant's capacity. + +### Dropped boat items +Boat items exist when thrown, dropped on death, or when a boat entity is broken. The hold contents travel with the item. + +1. **TTL:** every dropped boat item gets an expiry timestamp **stored in the database** (survives restarts/crashes). A periodic task removes overdue items by UUID, along with their contents. +2. Pickup, item **bigger** than your boat: it immediately replaces your boat — **the replaced boat is destroyed** (upgrade semantics, same as the shop); your capacity grows; your existing contents remain and the captured contents merge in. +3. Pickup, item **smaller or equal**: you keep your boat; contents transfer from the captured item **most-valuable-first** (BlueBook base price as the value oracle) until your hold is full. +4. If your hold fills mid-transfer, you're told the transfer is partial; the remainder stays in the dropped item (still under TTL) for anyone — including you, after destroying or selling to make room. +5. **Sanctioned use:** dropping a loaded boat so another player can pick it up is the intended way to hand goods over. Not an exploit. +6. Pickup with **no current boat**: the item becomes your boat with its contents intact *(assumption — confirm)*. + +### Edge cases (from Ben's doc) +- If the dropped item entity is destroyed by external means (another plugin, etc.), the inventory is lost — accept and clean the DB record. +- Boats must never be destroyed **except by lava**: trident hits, collisions, and other breakage always yield the item. +- On player death, the boat drops as an item with contents (except lava deaths). **`keepInventory` servers/regions:** the player simply keeps their boat and hold. **DeathChest servers:** the boat goes into the death chest; the player must buy a boat and fuel and physically travel there (no teleporting per DeathChest rules); on retrieving their old boat, normal pickup/upgrade rules apply and the recovery boat — being smaller — is the one removed. +- A mob (e.g. zombie) picking up the boat item: **works for free under this model** — contents are DB-keyed via the ItemStack's PDC, which survives mob pickup; kill the zombie, the item drops, hold intact. TTL handling while mob-held: pause or extend TTL while an entity holds it (recommend: pause). +- Overfull situations (e.g. capacity shrinks by admin action): player must choose what to keep; open the GUI in triage mode where excess must be destroyed before normal use resumes. + +## Data model + +``` +PlayerHold (DataObject) DroppedBoat (DataObject) + uniqueId : player UUID uniqueId : hold/item UUID (in item PDC) + boat : material or none boat : material + contents : [{material, amt}] contents : [{material, amt}] + fuel : [{material, amt}] fuel : [{material, amt}] + expiresAt : timestamp (TTL) +``` +Ownership transitions (drop → pickup → replace) are atomic DB moves between the two objects. Duplicating a key item cannot duplicate cargo — contents exist once, server-side. + +## Trade & customs integration +- Plaza trading transacts against the player's hold — with One Boat there is never ambiguity. +- Customs scans the player's hold (and the dropped-boat record if they're carrying a boat item — not possible under One Boat, but guard anyway). +- Smuggler evidence-dumping = destroying contraband via the TNT slot before police close. +- Fuel checks (warp dialog, low-fuel warnings) read the fuel slots only. + +## Resolved rulings (this revision) +1. Expanders **kept**, Pale-Oak-Chest-only, nested-inventory model (see Expanders). +2. Boat pricing **quadratic**, expanders **doubling** (see Pricing) — proposed defaults awaiting veto. +3. Replaced boats are **destroyed** — purchases and bigger-boat pickups are upgrades. +4. Customs stamping **removed entirely**. +5. `keepInventory` / DeathChest behavior defined (see edge cases). +6. Destroy-only jettison confirmed. + +Remaining proposed-not-ruled defaults, all in the Expanders section: nested size 21, no nesting, empty-before-destroy, openable only while installed in a Pale Oak Chest Boat, sold at industrial islands. + +## Acceptance tests +- One Boat invariant holds through every path: shop, craft, pickup, death, admin gives. +- Upgrade (shop/craft/chest-add) preserves contents exactly; smaller purchases/crafts impossible. +- Dropped-item TTL fires across a server restart; mob-held items survive with contents. +- No GUI interaction sequence extracts cargo to player inventory; fuel moves freely both ways; TNT destroys cargo, never fuel. +- Pickup transfer is most-valuable-first, partial transfers leave the remainder intact and claimable. +- Death always yields the boat item with contents (except lava); trident/collision yields the item, never destruction. +- Expanders: installable only in Pale Oak Chest Boats; nested GUI never accepts containers or expanders; TNT refuses a non-empty expander; nth-expander price = base × 2ⁿ; expander riding in a smaller boat after pickup is inert but its contents survive round-trips. +- Tech gating: a TL n shop never lists boats above rank 3n or (below TL7) expanders; crafting is unaffected by tech level. diff --git a/files/tradewinds-overview.md b/files/tradewinds-overview.md new file mode 100644 index 0000000..309fee9 --- /dev/null +++ b/files/tradewinds-overview.md @@ -0,0 +1,33 @@ +# TradeWinds +### A BentoBox gamemode of sea trading, smuggling, and piracy + +**The pitch:** Your players are merchant sailors in an endless procedurally generated ocean dotted with trading islands. They start with nothing but a boat and a single trading bundle, and build a fortune by buying goods cheap on one island and selling them dear on another. How they get rich is up to them — honest hauling, smuggling contraband past customs patrols, hunting bounties, or outright piracy. Every choice has a price, and the game keeps score. + +--- + +## The world +The ocean is generated from a single shareable seed. Trading islands appear at random positions across the ocean — sparsely scattered, and always far enough apart that you can never see one island from another. Each has its own biome, procedurally generated name, and economic character: farm islands sell food cheap, industrial islands want raw materials, and so on. Islands also carry a **tech level** — only advanced ports sell the bigger ships, so wealth alone won't upgrade you; you have to sail somewhere that can build it. Islands generate lazily as players push outward, so the world grows with your community. Villagers run the markets; the islands themselves are protected — no building or breaking. + +Each island has a **security level**, from *safe* (no PvP, no hostile mobs, heavy policing) down to *anarchic* (anything goes). The best profit margins — and the black markets — are in dangerous space. + +## Getting around +Boats are everything. Players row freely between islands, or reach an island's border and **warp**: a fuel-powered jump straight to any charted island, paid for with wood, coal, or lava carried in the boat's hold. Warping is fast but fuel and cargo compete for the same space — the game's central tradeoff. Rowing is free but slow, and the open ocean between islands is lawless. + +Warps occasionally *fail*, dropping the player into a hostile Nether sea mid-route with Ghasts inbound. Fight or re-engage the warp — the detour is free, the fright isn't. + +Cargo grows in stages: bundles → chest boat → purchasable cargo expanders, with costs that scale steeply. A maxed-out trade ship carries a fortune and is worth defending — or attacking. + +## Law and disorder +Every player has a **reputation**. Attacking villagers, killing innocents, or trading contraband (sugar, by default) drives it down; clean living slowly restores it. Slip too far and you're *Wanted*: open to PvP everywhere, carrying a bounty any player can collect, and met at civilized islands by the police — Iron Golems on land, Guardians at sea, Phantoms in the air. A wanted player's bounty hangs right over their head for all to see (configurable) — a target sign for hunters, a badge of infamy for pirates. + +Entering an island's waters triggers a **customs scan**. Get flagged with contraband and the patrol launches — flee, fight, or dump the evidence overboard before they close in. Smuggling is a skill, not a tax. + +Players who earn enough can **buy their own island**, placed in the open ocean between trading islands, and eventually sell their own crafted goods into the economy. + +## For admins +- Runs on **BentoBox** alongside your other gamemodes; prices derive from the **BlueBook** economy addon. +- Nearly everything is a config knob: island spacing, fuel costs and warp-route pricing, scan chances, police response size, reputation thresholds, warp failure rate, cargo expansion caps. +- **Contraband and slave-trading mechanics can be disabled entirely** for family-friendly servers. +- One seed defines the whole galaxy — share it and another server gets the same world. + +*Inspired by the classic trading games TradeWars 2002 and Elite — rebuilt entirely in Minecraft terms.* diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 0cf1879..3d82225 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -604,10 +604,13 @@ private static Map defaultBoatRanks() { return ranks; } - @ConfigComment("Stock units for a full price swing. Selling this much of a category to one") - @ConfigComment("island drives its prices to the drift minimum.") - @ConfigEntry(path = "economy.drift-scale") - private int driftScale = 500; + @ConfigComment("COINS of stock for a full price swing - the size of a trader's purse.") + @ConfigComment("A port's capacity is its capacity to SPEND, not to count crates: ten") + @ConfigComment("diamonds are far more business than ten wheat, so drift is valued in money.") + @ConfigComment("Combined with drift-min below, a port saturates after absorbing") + @ConfigComment("(1 - drift-min) x this many coins of one category - then sail on.") + @ConfigEntry(path = "economy.drift-value-scale") + private int driftValueScale = 30000; @ConfigComment("Lower clamp of the stock drift price factor.") @ConfigEntry(path = "economy.drift-min") @@ -617,9 +620,11 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "economy.drift-max") private double driftMax = 1.3; - @ConfigComment("Stock units that decay back toward equilibrium per hour - markets recover.") - @ConfigEntry(path = "economy.stock-decay-per-hour") - private int stockDecayPerHour = 50; + @ConfigComment("Coins of stock that decay back toward equilibrium per hour - markets recover") + @ConfigComment("as the trader works through the inventory. A fully saturated category") + @ConfigComment("recovers in about (1 - drift-min) x drift-value-scale / this many hours.") + @ConfigEntry(path = "economy.stock-decay-value-per-hour") + private int stockDecayValuePerHour = 3000; @ConfigComment("Base price of the first cargo expander. Each further one costs double.") @ConfigEntry(path = "economy.expander-base-price") @@ -3024,14 +3029,14 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setRespawnBoat(String respawnBoat) { this.respawnBoat = respawnBoat; } public int getDroppedBoatTtlMinutes() { return droppedBoatTtlMinutes; } public void setDroppedBoatTtlMinutes(int droppedBoatTtlMinutes) { this.droppedBoatTtlMinutes = droppedBoatTtlMinutes; } - public int getDriftScale() { return driftScale; } - public void setDriftScale(int driftScale) { this.driftScale = driftScale; } + public int getDriftValueScale() { return driftValueScale; } + public void setDriftValueScale(int driftValueScale) { this.driftValueScale = driftValueScale; } public double getDriftMin() { return driftMin; } public void setDriftMin(double driftMin) { this.driftMin = driftMin; } public double getDriftMax() { return driftMax; } public void setDriftMax(double driftMax) { this.driftMax = driftMax; } - public int getStockDecayPerHour() { return stockDecayPerHour; } - public void setStockDecayPerHour(int stockDecayPerHour) { this.stockDecayPerHour = stockDecayPerHour; } + public int getStockDecayValuePerHour() { return stockDecayValuePerHour; } + public void setStockDecayValuePerHour(int stockDecayValuePerHour) { this.stockDecayValuePerHour = stockDecayValuePerHour; } public double getExpanderBasePrice() { return expanderBasePrice; } public void setExpanderBasePrice(double expanderBasePrice) { this.expanderBasePrice = expanderBasePrice; } public int getCharityCooldownMinutes() { return charityCooldownMinutes; } diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java b/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java index 0cb96de..eab805a 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java @@ -12,6 +12,9 @@ * Cache-in-front-of-Database manager for {@link TWIslandData}. Stock decays * toward equilibrium lazily on access - markets recover from gluts and * shortages over real time. + *

+ * Stock is denominated in coins (the trader's purse), so a port's capacity to + * absorb goods is a capacity to spend money, not to count crates. * * @author tastybento */ @@ -43,28 +46,65 @@ private TWIslandData get(IslandSpec spec) { } /** - * The island's current stock for a category, decayed to now. + * The island's current stock position for a category in coins, decayed to + * now. */ - public int getStock(IslandSpec spec, TradeCategory category) { + public int getStockValue(IslandSpec spec, TradeCategory category) { TWIslandData data = get(spec); decay(data); - return data.getStock().getOrDefault(category.name(), 0); + return data.getStockValue().getOrDefault(category.name(), 0); } /** - * Adjust stock after a trade: players selling to the island push stock up - * (prices fall); buying pulls it down (prices rise). + * Adjust a category's stock position after a trade, by the coin value + * of the transaction: players selling to the island push it up (prices + * fall); buying pulls it down (prices rise). + * + * @param spec the island + * @param category the trade category + * @param coinDelta transaction value, positive when the player sold here */ - public void adjustStock(IslandSpec spec, TradeCategory category, int delta) { + public void adjustStockValue(IslandSpec spec, TradeCategory category, int coinDelta) { TWIslandData data = get(spec); decay(data); - data.getStock().merge(category.name(), delta, Integer::sum); + data.getStockValue().merge(category.name(), coinDelta, Integer::sum); handler.saveObjectAsync(data); } + /** + * How much more value this port will absorb in a category before its price + * hits the drift floor - the number a seller actually needs, because it says + * when to stop selling and sail on. + * + * @param spec the island + * @param category the trade category + * @return coins of headroom, never negative + */ + public int absorbableValue(IslandSpec spec, TradeCategory category) { + var settings = addon.getSettings(); + return absorbable(saturationValue(settings.getDriftMin(), settings.getDriftValueScale()), + getStockValue(spec, category)); + } + + /** + * The stock position at which a category's price reaches the drift floor - + * past this a port pays no less however much more you sell it. Pure and + * package-visible for tests. + */ + static int saturationValue(double driftMin, int driftValueScale) { + return (int) Math.round((1.0 - driftMin) * driftValueScale); + } + + /** + * Headroom left before saturation. Pure and package-visible for tests. + */ + static int absorbable(int saturationValue, int stockValue) { + return Math.max(0, saturationValue - stockValue); + } + /** * Lazy decay: every category trends toward equilibrium 0 at the configured - * units per hour since the last pass. + * coins per hour since the last pass. */ private void decay(TWIslandData data) { long now = System.currentTimeMillis(); @@ -72,8 +112,9 @@ private void decay(TWIslandData data) { if (hours <= 0) { return; } - int perHour = addon.getSettings().getStockDecayPerHour(); - data.getStock().replaceAll((cat, stock) -> decayed(stock, (int) Math.min(Integer.MAX_VALUE, hours * perHour))); + int perHour = addon.getSettings().getStockDecayValuePerHour(); + data.getStockValue() + .replaceAll((cat, stock) -> decayed(stock, (int) Math.min(Integer.MAX_VALUE, hours * perHour))); data.setLastDecay(now); } diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java index 7925411..40b8bec 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java @@ -23,11 +23,16 @@ public class TWIslandData implements DataObject { private String uniqueId; /** - * Stock per trade category name. Positive = players sold a lot here - * (prices depressed); negative = bought out (prices raised). + * Stock per trade category name, measured in coins of inventory + * position rather than item count - the trader's purse. Positive = + * players sold a lot of value here (prices depressed); negative = bought + * out (prices raised). + *

+ * Coins, not units, because ten diamonds and ten wheat are not the same + * business to a port, and counting units said they were. */ @Expose - private Map stock = new HashMap<>(); + private Map stockValue = new HashMap<>(); /** * Epoch millis of the last stock decay pass. @@ -54,12 +59,12 @@ public void setUniqueId(String uniqueId) { this.uniqueId = uniqueId; } - public Map getStock() { - return stock; + public Map getStockValue() { + return stockValue; } - public void setStock(Map stock) { - this.stock = stock; + public void setStockValue(Map stockValue) { + this.stockValue = stockValue; } public long getLastDecay() { diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 3287adf..7d91050 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -119,7 +119,7 @@ public int buyToInventory(Player player, IslandSpec spec, Material material, int public PriceModel model() { var s = addon.getSettings(); return new PriceModel(s.getProduceFactor(), s.getDemandFactor(), s.getBandDemandBonus(), s.getBuySpread(), - s.getSellSpread(), s.getDriftScale(), s.getDriftMin(), s.getDriftMax(), s.getTechPriceStep()); + s.getSellSpread(), s.getDriftValueScale(), s.getDriftMin(), s.getDriftMax(), s.getTechPriceStep()); } /** @@ -217,7 +217,7 @@ private double factor(IslandSpec spec, Material material) { boolean demands = contraband || TypeEconomy.demands(spec.type()).contains(category); boolean produces = !contraband && TypeEconomy.produces(spec.type()).contains(category); double economic = model().economicFactor(produces, demands, spec.band().ordinal(), - addon.getIslandDataManager().getStock(spec, category)); + addon.getIslandDataManager().getStockValue(spec, category)); // The tech tilt: high tech sells finished cheap and buys raw dear, so // the best routes are tech DIFFERENTIALS. Contraband is exempt - the // black market premium is its own lever and answers to nothing else. @@ -267,7 +267,11 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { int removed = addon.getHoldService().remove(player, material, count); double total = PriceModel.round2(removed * unitPrice.get()); vault.get().deposit(User.getInstance(player), total); - addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), removed); + // Drift moves on the VALUE of the trade, not the item count: a port that + // has just paid out for ten diamonds is far closer to saturated than one + // that bought ten wheat + addon.getIslandDataManager().adjustStockValue(spec, TradeCategory.of(material), + (int) Math.round(total)); User.getInstance(player).sendMessage("tradewinds.trade.sold", "[amount]", String.valueOf(removed), "[material]", pretty(material), "[price]", Money.format(addon, total)); chime(player); @@ -315,7 +319,8 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { } double total = PriceModel.round2(added * unitPrice.get()); vault.get().withdraw(user, total); - addon.getIslandDataManager().adjustStock(spec, TradeCategory.of(material), -added); + addon.getIslandDataManager().adjustStockValue(spec, TradeCategory.of(material), + -(int) Math.round(total)); user.sendMessage("tradewinds.trade.bought", "[amount]", String.valueOf(added), "[material]", pretty(material), "[price]", Money.format(addon, total)); chime(player); diff --git a/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java b/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java index a359727..7a23548 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java +++ b/src/main/java/world/bentobox/tradewinds/economy/PriceModel.java @@ -8,13 +8,17 @@ * and the stock drift (players selling into an island depress its prices; * buying it out raises them). The buy/sell spread means a same-island round * trip always loses money. + *

+ * Stock is measured in coins of inventory position, not items: a port + * absorbing a thousand coins' worth of diamonds is doing far more business than + * one absorbing a thousand cobblestones, and counting units said otherwise. * * @param produceFactor price multiplier where the island produces the category * @param demandFactor price multiplier where the island demands the category * @param bandDemandBonus extra demand multiplier per security band step * @param buySpread multiplier on what players pay the island * @param sellSpread multiplier on what the island pays players - * @param driftScale stock units for full drift swing + * @param driftValueScale coins of inventory position for a full drift swing * @param driftMin lower clamp of the drift factor * @param driftMax upper clamp of the drift factor * @param techPriceStep price tilt per tech-level step from 4: high tech sells @@ -23,14 +27,14 @@ * @author tastybento */ public record PriceModel(double produceFactor, double demandFactor, double bandDemandBonus, double buySpread, - double sellSpread, int driftScale, double driftMin, double driftMax, double techPriceStep) { + double sellSpread, int driftValueScale, double driftMin, double driftMax, double techPriceStep) { /** * Convenience constructor without the tech tilt (tests, tech-neutral use). */ public PriceModel(double produceFactor, double demandFactor, double bandDemandBonus, double buySpread, - double sellSpread, int driftScale, double driftMin, double driftMax) { - this(produceFactor, demandFactor, bandDemandBonus, buySpread, sellSpread, driftScale, driftMin, driftMax, + double sellSpread, int driftValueScale, double driftMin, double driftMax) { + this(produceFactor, demandFactor, bandDemandBonus, buySpread, sellSpread, driftValueScale, driftMin, driftMax, 0.0); } @@ -55,12 +59,16 @@ public double techFactor(boolean finished, boolean raw, int techLevel) { /** * Stock drift: positive stock (players sold a lot here) depresses prices, * negative stock (players bought the island out) raises them. + *

+ * The clamps mean only the first {@code (1 - driftMin) * driftValueScale} + * coins actually move the price - past that a port is simply saturated and + * the seller has to go somewhere else, which is the whole point. * - * @param stock current stock relative to equilibrium 0 + * @param stockValue coins of inventory position, relative to equilibrium 0 * @return clamped drift factor */ - public double driftFactor(int stock) { - return Math.clamp(1.0 - (double) stock / driftScale, driftMin, driftMax); + public double driftFactor(int stockValue) { + return Math.clamp(1.0 - (double) stockValue / driftValueScale, driftMin, driftMax); } /** @@ -69,17 +77,17 @@ public double driftFactor(int stock) { * @param produces the island produces this category * @param demands the island demands this category * @param bandOrdinal security band ordinal (0 = SAFE) - * @param stock current stock + * @param stockValue coins of inventory position * @return combined price factor */ - public double economicFactor(boolean produces, boolean demands, int bandOrdinal, int stock) { + public double economicFactor(boolean produces, boolean demands, int bandOrdinal, int stockValue) { double factor = 1.0; if (produces) { factor = produceFactor; } else if (demands) { factor = demandFactor * (1.0 + bandDemandBonus * bandOrdinal); } - return factor * driftFactor(stock); + return factor * driftFactor(stockValue); } /** diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index f0e26b8..6850b98 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -271,11 +271,16 @@ economy: # goods (ores, crops, wood, fish, stone) by this fraction per step; low-tech # islands the inverse. The best routes are tech differentials. 0 disables. tech-price-step: 0.03 - # Stock units for a full price swing; drift clamps; hourly decay toward equilibrium. - drift-scale: 500 + # COINS of stock for a full price swing - the size of a trader's purse. A port's + # capacity is its capacity to SPEND, not to count crates: ten diamonds are far + # more business than ten wheat. With drift-min 0.7 a port saturates after + # absorbing 30% of this in one category, then pays the floor until it recovers. + drift-value-scale: 30000 drift-min: 0.7 drift-max: 1.3 - stock-decay-per-hour: 50 + # Coins of stock decaying back toward equilibrium per hour - markets recover as + # the trader works through the inventory (9000 coins saturated / 3000 = 3 hours). + stock-decay-value-per-hour: 3000 # Cargo expanders: first price, doubling each purchase, hard cap per player. expander-base-price: 50000.0 expander-cap: 4 diff --git a/src/test/java/world/bentobox/tradewinds/dataobjects/IslandDataManagerTest.java b/src/test/java/world/bentobox/tradewinds/dataobjects/IslandDataManagerTest.java index effea18..a4b4d67 100644 --- a/src/test/java/world/bentobox/tradewinds/dataobjects/IslandDataManagerTest.java +++ b/src/test/java/world/bentobox/tradewinds/dataobjects/IslandDataManagerTest.java @@ -5,7 +5,8 @@ import org.junit.jupiter.api.Test; /** - * Tests the stock decay math: markets trend back to equilibrium. + * Tests the stock math: markets trend back to equilibrium, and a port's + * capacity to absorb goods is measured in coins it will spend. * * @author tastybento */ @@ -22,4 +23,26 @@ void testDecayTowardZero() { // Equilibrium stays put assertEquals(0, IslandDataManager.decayed(0, 50)); } + + @Test + void testSaturationIsWhereTheFloorBites() { + // Only the first (1 - driftMin) x scale coins move the price at all: + // with the shipped 0.7 / 30000, a port saturates at 9000 coins + assertEquals(9000, IslandDataManager.saturationValue(0.7, 30000)); + // A deeper floor means a port that keeps paying for longer + assertEquals(15000, IslandDataManager.saturationValue(0.5, 30000)); + } + + @Test + void testAbsorbableHeadroom() { + // A fresh port will take the full purse + assertEquals(9000, IslandDataManager.absorbable(9000, 0)); + // Half sold in, half left + assertEquals(4500, IslandDataManager.absorbable(9000, 4500)); + // Saturated, and never negative - "no more" is the answer, not "-3000 more" + assertEquals(0, IslandDataManager.absorbable(9000, 9000)); + assertEquals(0, IslandDataManager.absorbable(9000, 12000)); + // A port that has been BOUGHT out is hungrier than a fresh one + assertEquals(11000, IslandDataManager.absorbable(9000, -2000)); + } } diff --git a/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java b/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java index 7eca3df..31219bf 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/PriceModelTest.java @@ -48,7 +48,9 @@ void testMarginsScaleWithDanger() { @Test void testStockDrift() { - // Flooding an island with goods depresses its prices + // Stock is COINS of inventory position, not item count. This model uses + // a 500-coin purse, so 250 coins sold in is half a swing. + // Flooding an island with value depresses its prices assertEquals(1.0, model.driftFactor(0)); assertTrue(model.driftFactor(250) < 1.0); assertEquals(0.7, model.driftFactor(5000)); // clamped @@ -57,6 +59,32 @@ void testStockDrift() { assertEquals(1.3, model.driftFactor(-5000)); // clamped } + @Test + void testDriftFollowsMoneyNotVolume() { + // The point of valuing drift in coins (adopted 2026-08-03): ten diamonds + // and a boatload of wheat are the same business to a port if they cost + // the same, and counting units said the wheat mattered 100x more. + int tenDiamonds = 10 * 850; + int wheatByTheTonne = 850 * 10; + assertEquals(model.driftFactor(tenDiamonds), model.driftFactor(wheatByTheTonne)); + + // And a genuinely small sale barely registers, whatever it is made of + assertTrue(model.driftFactor(20) > 0.95); + } + + @Test + void testAPortSaturatesAndTheSellerMustSailOn() { + // Selling in progressively saturates a port: each sale pays less... + double fresh = model.playerSellsAt(100.0, model.economicFactor(false, true, 0, 0)); + double halfway = model.playerSellsAt(100.0, model.economicFactor(false, true, 0, 75)); + double saturated = model.playerSellsAt(100.0, model.economicFactor(false, true, 0, 150)); + assertTrue(halfway < fresh, "Selling in must depress the price: " + fresh + " -> " + halfway); + assertTrue(saturated < halfway); + // ...until the floor, past which dumping more is pure waste and the + // answer is another port, not another crate + assertEquals(saturated, model.playerSellsAt(100.0, model.economicFactor(false, true, 0, 10_000))); + } + @Test void testExpanderPricingDoubles() { assertEquals(5000.0, PriceModel.expanderPrice(5000, 0)); From b1e4f4cd51b056c0ecb671bcd7242fdbb1ecfe5f Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:23:40 -0700 Subject: [PATCH 079/112] Give salvage a market of its own (Stage 7.5 Phase 2) Scavenged loot now sells, at economy.salvage-discount (0.375) of book, into a stock pool of its own. Salvage is decided by whether a material is on any island's shelves rather than by its name, which is what leaves the existing trade economy untouched - an iron ingot is cargo exactly where it always was, while a rabbit's foot was never on anyone's manifest. The separate pool is the load-bearing part: sharing one would mean a boatload of dumped junk cratering the same category's honest cargo for every trader at that port. Around 50 natural drops are now priced. None of them has any recipe, so the engine could never have derived them however deep it recursed. The actual reason so little was sellable, though, was a config bug: BentoBox REPLACES map settings from config.yml rather than merging them, and the shipped config carried 27 of the 112 base prices. Around 50 goods were unsellable on every real server while the unit tests, which read the code default, passed happily. The config block is now complete and generated from the code table, and SettingsTest fails the build if the two drift again - plus a second guard that every price key resolves to a real Material, which also catches renames like SCUTE -> TURTLE_SCUTE. The planned headless coverage audit became /twadmin priceaudit instead. Most prices are derived from crafting recipes and MockBukkit ships no vanilla recipe set, so headless it would report nearly everything unpriceable - a confident wrong answer. The live registry is the only place that truth lives. 264 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 7 ++ TESTING.md | 9 ++ docs/PROGRESS.md | 36 ++++++ .../world/bentobox/tradewinds/Settings.java | 35 ++++++ .../world/bentobox/tradewinds/TradeWinds.java | 2 + .../commands/AdminPriceAuditCommand.java | 109 +++++++++++++++++ .../tradewinds/economy/MarketService.java | 50 ++++++-- .../tradewinds/economy/TradeCategory.java | 23 +++- .../tradewinds/economy/TypeEconomy.java | 29 +++++ src/main/resources/config.yml | 98 ++++++++++++++- src/main/resources/locales/en-US.yml | 8 ++ .../bentobox/tradewinds/SettingsTest.java | 31 +++++ .../economy/SalvagePricingTest.java | 115 ++++++++++++++++++ .../tradewinds/economy/TradeCategoryTest.java | 40 ++++++ 14 files changed, 580 insertions(+), 12 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/AdminPriceAuditCommand.java create mode 100644 src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java diff --git a/CLAUDE.md b/CLAUDE.md index fcf1f51..24e177b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,13 @@ More traps, each of which cost a playtest: - Bare `yes`/`no`/`on`/`off` are YAML 1.1 **booleans** — never use them as locale keys. `ResourceYamlTest` fails the build on duplicate keys, because Bukkit only warns and then silently drops one. +- **BentoBox REPLACES `Map` settings from `config.yml`, it does not merge them** + (`YamlDatabaseHandler.deserializeMap`). A partial map in the shipped config + silently overrides the whole code default — `economy.base-prices` shipped 27 + of 112 entries, so ~50 goods were unsellable on every real server while the + unit tests, which read the code default, were perfectly happy. Any `@ConfigEntry` + map must be complete in `config.yml`; `SettingsTest` now fails the build if the + two drift. ## Reference repos (all local) diff --git a/TESTING.md b/TESTING.md index e335bb1..49d449a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,6 +70,15 @@ noise. - [ ] Warp: you and the boat arrive facing the **dock**; hold forward and you reach it. - [ ] Sell the cargo at the new island at a different price than you paid. +- [ ] **Scavenged loot sells.** Kill a few mobs on an islet, load bones, string, + rotten flesh and gunpowder into the hold, and sell at any port: every one + of them has a price, visibly lower than a trade good of similar book value. +- [ ] `/twadmin priceaudit` writes `price-audit.txt` to the addon folder. Read + the UNPRICEABLE list: anything a player could plausibly acquire and carry + wants a base price adding. +- [ ] **Dumping junk does not move honest cargo.** Note a port's iron price, sell + it a boatload of dirt and rotten flesh, and check the iron price is + unchanged - salvage drifts in its own pool. - [ ] **Selling in saturates a port by value, not volume.** Sell ~7 diamonds (or 12 emeralds) at one island: the unit price should fall to the floor and stop falling. Selling a few hundred wheat should move it about as much as diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2dd2f80..9cd00d0 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,42 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 7.5 Phase 2 — salvage has a market (2026-08-03) + +Scavenged loot is now sellable, at a discount, into a stock pool of its own. + +- **`TradeCategory.SALVAGE`**, which `of()` never returns: salvage is decided by + whether a material is on any island's shelves (`TypeEconomy.tradeGoods()` - + the union of every type catalog and outfitter shelf), not by its name. That + leaves the existing trade economy completely untouched; an iron ingot is cargo + exactly where it always was. No type produces or demands SALVAGE, so it is + affinity- and tech-neutral, and `economy.salvage-discount` (0.375) is applied + on top. +- A **separate stock pool** is the load-bearing part: without it, dumping a + boatload of junk would crater the same category's honest cargo prices for every + trader at that port, which is a griefing vector on a small server. +- **~50 natural drops priced** (mob loot, foraged food, dug blocks). None of + these has any recipe, so the engine could never derive them however deep it + recursed - they had to be stated or a scavenger's whole haul was unsellable. +- **`/twadmin priceaudit`** writes a full coverage report (unpriceable / salvage / + trade goods) to `price-audit.txt`. + +**The real bug behind the complaint.** BentoBox **replaces** `Map` settings from +`config.yml` rather than merging them (`YamlDatabaseHandler.deserializeMap`), and +the shipped `config.yml` carried **27 of 112** base prices. So ~50 goods were +unsellable on every real server while the unit tests - which read the code +default - passed happily. The config block is now complete and generated from the +code table, and `SettingsTest` fails the build if the two ever drift again. A +second new test asserts every price key resolves to a real `Material`, which also +catches Minecraft renames like `SCUTE` -> `TURTLE_SCUTE`. + +**Why the audit is a command and not a unit test.** The plan called for a +headless coverage test, but most prices are *derived* from crafting recipes and +MockBukkit ships no vanilla recipe set - headless, nearly everything reports +unpriceable, which is a confident wrong answer. The live registry is the only +place the truth lives. The headless guards test what they can actually see: name +validity and config/code agreement. + ## Stage 7.5 Phase 1 — the trader's purse (2026-08-03) Planned in `tradewinds-salvage-plan.md`. Stock drift was counted in **items**, diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 3d82225..b915c01 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -646,6 +646,14 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "player.max-restarts") private int maxRestarts = 3; + @ConfigComment("What a port pays for SALVAGE - anything that is not a recognised trade good") + @ConfigComment("on some island's shelves: mob drops, worn gear, odd blocks. A fraction of book") + @ConfigComment("price. Keep this well under 1: if scavenging and piracy pay as well as trading,") + @ConfigComment("they replace it rather than feeding it. Salvage also drifts against its own") + @ConfigComment("stock pool, so dumping junk cannot crater a port's legitimate cargo prices.") + @ConfigEntry(path = "economy.salvage-discount") + private double salvageDiscount = 0.375; + @ConfigComment("Base prices (Material -> price). Anything not listed is priced by deriving") @ConfigComment("from its crafting recipe (BlueBook logic, embedded); underivable = untradeable.") @ConfigEntry(path = "economy.base-prices") @@ -671,6 +679,31 @@ private static Map defaultBasePrices() { map.put("QUARTZ", 80.0); map.put("LAPIS_LAZULI", 60.0); map.put("REDSTONE", 30.0); map.put("LEATHER", 40.0); map.put("WHITE_WOOL", 20.0); map.put("STRING", 15.0); map.put("BEEF", 25.0); map.put("PORKCHOP", 25.0); map.put("CHICKEN", 20.0); map.put("MUTTON", 20.0); + // Natural drops: no recipe exists for any of these, so the engine can + // never derive them however deep it recurses - they have to be stated or + // a scavenger's whole haul is unsellable. This is the salvage economy's + // raw material (Stage 7.5 Phase 2). + map.put("BONE", 20.0); map.put("BONE_MEAL", 8.0); map.put("GUNPOWDER", 50.0); + map.put("SPIDER_EYE", 30.0); map.put("ROTTEN_FLESH", 5.0); map.put("FEATHER", 15.0); + map.put("INK_SAC", 25.0); map.put("GLOW_INK_SAC", 90.0); map.put("SLIME_BALL", 40.0); + map.put("RABBIT", 20.0); map.put("RABBIT_HIDE", 15.0); map.put("RABBIT_FOOT", 150.0); + map.put("ENDER_PEARL", 250.0); map.put("BLAZE_ROD", 200.0); map.put("GHAST_TEAR", 400.0); + map.put("PHANTOM_MEMBRANE", 180.0); map.put("NETHER_STAR", 5000.0); + map.put("PRISMARINE_SHARD", 60.0); map.put("PRISMARINE_CRYSTALS", 90.0); + map.put("NAUTILUS_SHELL", 300.0); map.put("HEART_OF_THE_SEA", 2500.0); + map.put("SHULKER_SHELL", 800.0); map.put("ECHO_SHARD", 600.0); + map.put("TOTEM_OF_UNDYING", 4000.0); map.put("DRAGON_BREATH", 500.0); + map.put("TURTLE_SCUTE", 120.0); map.put("ARMADILLO_SCUTE", 60.0); map.put("HONEYCOMB", 40.0); + map.put("SADDLE", 300.0); map.put("NAME_TAG", 400.0); + // Foraged and gathered: apple and melon drop from leaves and vines, the + // rest are picked, dug or fished up + map.put("APPLE", 25.0); map.put("MELON", 35.0); map.put("SWEET_BERRIES", 10.0); + map.put("GLOW_BERRIES", 30.0); map.put("COCOA_BEANS", 20.0); map.put("BAMBOO", 5.0); + map.put("CACTUS", 10.0); map.put("BROWN_MUSHROOM", 15.0); map.put("RED_MUSHROOM", 15.0); + map.put("SEA_PICKLE", 25.0); map.put("SPONGE", 200.0); map.put("NETHER_WART", 30.0); + map.put("CLAY_BALL", 8.0); map.put("SNOWBALL", 3.0); map.put("DIRT", 2.0); + map.put("OBSIDIAN", 80.0); map.put("NETHERRACK", 3.0); map.put("SOUL_SAND", 15.0); + map.put("END_STONE", 20.0); map.put("GLOWSTONE_DUST", 40.0); map.put("MAGMA_CREAM", 90.0); // Hulls: priced above raw plank cost - shipwright labor. Frugal // players craft their own from wild-islet timber. map.put("OAK_BOAT", 200.0); @@ -3045,6 +3078,8 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setContrabandMaterials(List contrabandMaterials) { this.contrabandMaterials = contrabandMaterials; } public int getMaxRestarts() { return maxRestarts; } public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } + public double getSalvageDiscount() { return salvageDiscount; } + public void setSalvageDiscount(double salvageDiscount) { this.salvageDiscount = salvageDiscount; } public Map getBasePrices() { return basePrices; } public void setBasePrices(Map basePrices) { this.basePrices = basePrices; } public double getIntersticeGhastChance() { return intersticeGhastChance; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 45478cf..f7de22b 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -26,6 +26,7 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.lists.Flags; import world.bentobox.tradewinds.commands.AdminCustomsCommand; +import world.bentobox.tradewinds.commands.AdminPriceAuditCommand; import world.bentobox.tradewinds.commands.AdminIslandsCommand; import world.bentobox.tradewinds.commands.AdminReflagCommand; import world.bentobox.tradewinds.commands.AdminTpIslandCommand; @@ -206,6 +207,7 @@ public void setup() { new AdminReflagCommand(this); new AdminCustomsCommand(this); new AdminWarpFailCommand(this); + new AdminPriceAuditCommand(this); } }; } diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminPriceAuditCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminPriceAuditCommand.java new file mode 100644 index 0000000..bcab5a8 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminPriceAuditCommand.java @@ -0,0 +1,109 @@ +package world.bentobox.tradewinds.commands; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.economy.TypeEconomy; + +/** + * Audits price coverage: which materials can be priced, which cannot, and which + * are salvage rather than recognised trade goods. + *

+ * This has to run on a real server rather than as a unit test. Most prices are + * derived from crafting recipes, and MockBukkit ships no vanilla recipe + * set - headless, almost everything would report as unpriceable, which is a + * confident wrong answer. The live registry is the only place the truth lives. + *

+ * An unpriceable material is a good a player can carry and never sell, so the + * report is the to-do list for {@code economy.base-prices}. + * + * @author tastybento + */ +public class AdminPriceAuditCommand extends CompositeCommand { + + public AdminPriceAuditCommand(CompositeCommand parent) { + super(parent, "priceaudit"); + } + + @Override + public void setup() { + setPermission("admin.priceaudit"); + setOnlyPlayer(false); + setDescription("tradewinds.commands.admin.priceaudit.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + var engine = addon.getMarketService().getPriceEngine(); + Map tradeGoods = new TreeMap<>(); + Map salvage = new TreeMap<>(); + List unpriceable = new ArrayList<>(); + + for (Material material : Material.values()) { + // Legacy aliases and non-item blocks are not goods and would only + // pad the report + if (material.isLegacy() || !material.isItem() || material.isAir()) { + continue; + } + double price = engine.getPrice(new ItemStack(material)); + if (price <= 0) { + unpriceable.add(material.name()); + } else if (TypeEconomy.tradeGoods().contains(material)) { + tradeGoods.put(material.name(), price); + } else { + salvage.put(material.name(), price); + } + } + + user.sendMessage("tradewinds.commands.admin.priceaudit.header"); + user.sendMessage("tradewinds.commands.admin.priceaudit.trade-goods", TextVariables.NUMBER, + String.valueOf(tradeGoods.size())); + user.sendMessage("tradewinds.commands.admin.priceaudit.salvage", TextVariables.NUMBER, + String.valueOf(salvage.size())); + user.sendMessage("tradewinds.commands.admin.priceaudit.unpriceable", TextVariables.NUMBER, + String.valueOf(unpriceable.size())); + + Path report = addon.getDataFolder().toPath().resolve("price-audit.txt"); + try { + write(report, tradeGoods, salvage, unpriceable); + user.sendMessage("tradewinds.commands.admin.priceaudit.written", "[file]", report.toString()); + } catch (IOException e) { + addon.logError("Could not write the price audit: " + e.getMessage()); + user.sendMessage("tradewinds.commands.admin.priceaudit.write-failed"); + } + return true; + } + + private void write(Path report, Map tradeGoods, Map salvage, + List unpriceable) throws IOException { + Files.createDirectories(report.getParent()); + try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(report))) { + out.println("TradeWinds price audit"); + out.println(); + out.println("UNPRICEABLE (" + unpriceable.size() + ") - carryable but unsellable."); + out.println("Add a base price for anything here a player could plausibly acquire."); + unpriceable.forEach(name -> out.println(" " + name)); + out.println(); + out.println("SALVAGE (" + salvage.size() + ") - priced, but on no island's shelves,"); + out.println("so sold at the salvage discount into its own stock pool."); + salvage.forEach((name, price) -> out.printf(" %-34s %10.0f%n", name, price)); + out.println(); + out.println("TRADE GOODS (" + tradeGoods.size() + ") - stocked by some island type."); + tradeGoods.forEach((name, price) -> out.printf(" %-34s %10.0f%n", name, price)); + } + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 7d91050..1965ea9 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -207,23 +207,59 @@ public double contrabandPremium(Material material) { return Math.max(1.0, addon.getSettings().getContrabandPriceMultiplier()); } + /** + * Whether a material is a recognised trade good somewhere in the galaxy. + * Everything else is salvage: still sellable, but at a discount and into a + * pool of its own. + * + * @param material the material + * @return true if any island type stocks it + */ + public boolean isTradeGood(Material material) { + // Contraband is nobody's shelf stock but is emphatically not junk - it + // has the smuggler's premium instead + if (addon.getCustomsService() != null && addon.getCustomsService().isContraband(material)) { + return true; + } + return TypeEconomy.tradeGoods().contains(material); + } + + /** + * Which stock pool a material's trade drifts against. Salvage has its own, + * so dumping junk cannot crater the legitimate cargo of the same category. + * + * @param material the material + * @return the pool + */ + public TradeCategory driftPool(Material material) { + return isTradeGood(material) ? TradeCategory.of(material) : TradeCategory.SALVAGE; + } + private double factor(IslandSpec spec, Material material) { TradeCategory category = TradeCategory.of(material); boolean contraband = addon.getCustomsService() != null && addon.getCustomsService().isContraband(material); + boolean salvage = !isTradeGood(material); // A port that deals in contraband always wants it - it is not on // anybody's official produce list, and demand is what makes the band - // bonus apply, so the rougher the port the better it pays - boolean demands = contraband || TypeEconomy.demands(spec.type()).contains(category); - boolean produces = !contraband && TypeEconomy.produces(spec.type()).contains(category); + // bonus apply, so the rougher the port the better it pays. + // Salvage is on nobody's manifest either, but unlike contraband nobody + // is short of it: neutral affinity, no band bonus. + boolean demands = contraband || (!salvage && TypeEconomy.demands(spec.type()).contains(category)); + boolean produces = !contraband && !salvage && TypeEconomy.produces(spec.type()).contains(category); double economic = model().economicFactor(produces, demands, spec.band().ordinal(), - addon.getIslandDataManager().getStockValue(spec, category)); + addon.getIslandDataManager().getStockValue(spec, driftPool(material))); // The tech tilt: high tech sells finished cheap and buys raw dear, so // the best routes are tech DIFFERENTIALS. Contraband is exempt - the // black market premium is its own lever and answers to nothing else. if (!contraband) { economic *= model().techFactor(category.isFinished(), category.isRaw(), spec.techLevel()); } + // Salvage pays worse than proper cargo, or scavenging and piracy would + // out-earn the trade game they are supposed to orbit + if (salvage) { + economic *= addon.getSettings().getSalvageDiscount(); + } return economic; } @@ -270,8 +306,7 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { // Drift moves on the VALUE of the trade, not the item count: a port that // has just paid out for ten diamonds is far closer to saturated than one // that bought ten wheat - addon.getIslandDataManager().adjustStockValue(spec, TradeCategory.of(material), - (int) Math.round(total)); + addon.getIslandDataManager().adjustStockValue(spec, driftPool(material), (int) Math.round(total)); User.getInstance(player).sendMessage("tradewinds.trade.sold", "[amount]", String.valueOf(removed), "[material]", pretty(material), "[price]", Money.format(addon, total)); chime(player); @@ -319,8 +354,7 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { } double total = PriceModel.round2(added * unitPrice.get()); vault.get().withdraw(user, total); - addon.getIslandDataManager().adjustStockValue(spec, TradeCategory.of(material), - -(int) Math.round(total)); + addon.getIslandDataManager().adjustStockValue(spec, driftPool(material), -(int) Math.round(total)); user.sendMessage("tradewinds.trade.bought", "[amount]", String.valueOf(added), "[material]", pretty(material), "[price]", Money.format(addon, total)); chime(player); diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java index 24ecb1b..296092c 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeCategory.java @@ -12,7 +12,21 @@ * @author tastybento */ public enum TradeCategory { - CROPS, FOOD, FISH, WOOD, STONE, ORES, METALS, GEMS, LUXURY, MISC; + CROPS, FOOD, FISH, WOOD, STONE, ORES, METALS, GEMS, LUXURY, MISC, + + /** + * Not a recognised trade good anywhere in the galaxy - scavenged loot, + * worn gear, mob drops, odd blocks. No island type produces or demands it, + * so it gets neutral type affinity, its own stock pool, and a discount + * (see {@code economy.salvage-discount}). + *

+ * A separate pool is the load-bearing part: without it, dumping a boatload + * of junk would crater the price of the same category's legitimate cargo + * for every honest trader at that port - a griefing vector on a small + * server. {@link #of(Material)} never returns this; salvage is decided by + * whether the material is on any island's shelves, not by its name. + */ + SALVAGE; /** * Raw goods - what low-tech islands live on. High-tech ports pay over the @@ -22,6 +36,13 @@ public boolean isRaw() { return this == ORES || this == CROPS || this == WOOD || this == FISH || this == STONE; } + /** + * Whether this is the salvage pool rather than a real trade category. + */ + public boolean isSalvage() { + return this == SALVAGE; + } + /** * Finished goods - what high-tech islands produce. High-tech ports sell * these cheap, low-tech ports pay dearly for them. diff --git a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java index bd2e92c..15449e1 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java @@ -93,4 +93,33 @@ public static Set demands(IslandType type) { public static List catalog(IslandType type) { return produces(type).stream().flatMap(cat -> CATALOG.get(cat).stream()).toList(); } + + /** + * Every material that is a recognised trade good somewhere in the + * galaxy: the union of all type catalogs plus every outfitter shelf. + * Anything else a player turns up - mob drops, worn gear, odd blocks - is + * salvage, priced at a discount into its own stock pool. + *

+ * Defining salvage by the shelves rather than by category leaves the + * existing trade economy completely untouched: an iron ingot is cargo + * everywhere it always was, while a rabbit's foot was never on anyone's + * manifest. + * + * @return the trade-good set, immutable + */ + public static Set tradeGoods() { + return TRADE_GOODS; + } + + private static final Set TRADE_GOODS = buildTradeGoods(); + + private static Set buildTradeGoods() { + Set goods = java.util.EnumSet.noneOf(Material.class); + CATALOG.values().forEach(goods::addAll); + OUTFITTER_EXTRAS.values().forEach(goods::addAll); + // The universal outfitter essentials, guaranteed at every port + goods.add(Material.BREAD); + goods.add(Material.CHARCOAL); + return java.util.Collections.unmodifiableSet(goods); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 6850b98..2b9b803 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -290,38 +290,130 @@ economy: # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. unstamped-sellables: - SUGAR + # What a port pays for SALVAGE - anything not a recognised trade good on some + # island's shelves: mob drops, worn gear, odd blocks. A fraction of book price. + # Keep well under 1: if scavenging and piracy pay as well as trading they replace + # it rather than feeding it. Salvage drifts against its own stock pool, so dumping + # junk cannot crater a port's legitimate cargo prices. + salvage-discount: 0.375 # Base prices (Material -> price). Anything not listed is priced by deriving from # its crafting recipe (BlueBook logic, embedded); underivable = untradeable. base-prices: WHEAT: 20 CARROT: 15 POTATO: 15 + BEETROOT: 15 SUGAR_CANE: 10 SUGAR: 15 + PUMPKIN: 30 + MELON_SLICE: 5 BREAD: 30 COOKED_BEEF: 40 + COOKED_COD: 30 + CAKE: 200 + GOLDEN_APPLE: 1500 + EGG: 10 + HAY_BLOCK: 180 COD: 20 SALMON: 30 + TROPICAL_FISH: 50 + PUFFERFISH: 40 + KELP: 3 OAK_LOG: 15 SPRUCE_LOG: 15 + BIRCH_LOG: 15 DARK_OAK_LOG: 15 + ACACIA_LOG: 15 + JUNGLE_LOG: 15 + CHERRY_LOG: 20 STONE: 5 + COBBLESTONE: 3 + GRANITE: 4 + DIORITE: 4 + ANDESITE: 4 + DEEPSLATE: 6 + SAND: 3 + GRAVEL: 3 COAL: 40 CHARCOAL: 30 RAW_IRON: 60 RAW_COPPER: 30 RAW_GOLD: 120 + FLINT: 10 IRON_INGOT: 90 COPPER_INGOT: 40 GOLD_INGOT: 180 + IRON_NUGGET: 10 + GOLD_NUGGET: 20 DIAMOND: 1000 EMERALD: 600 AMETHYST_SHARD: 100 - CAKE: 200 - GOLDEN_APPLE: 1500 + QUARTZ: 80 + LAPIS_LAZULI: 60 + REDSTONE: 30 + LEATHER: 40 + WHITE_WOOL: 20 + STRING: 15 + BEEF: 25 + PORKCHOP: 25 + CHICKEN: 20 + MUTTON: 20 + BONE: 20 + BONE_MEAL: 8 + GUNPOWDER: 50 + SPIDER_EYE: 30 + ROTTEN_FLESH: 5 + FEATHER: 15 + INK_SAC: 25 + GLOW_INK_SAC: 90 + SLIME_BALL: 40 + RABBIT: 20 + RABBIT_HIDE: 15 + RABBIT_FOOT: 150 + ENDER_PEARL: 250 + BLAZE_ROD: 200 + GHAST_TEAR: 400 + PHANTOM_MEMBRANE: 180 + NETHER_STAR: 5000 + PRISMARINE_SHARD: 60 + PRISMARINE_CRYSTALS: 90 + NAUTILUS_SHELL: 300 + HEART_OF_THE_SEA: 2500 + SHULKER_SHELL: 800 + ECHO_SHARD: 600 + TOTEM_OF_UNDYING: 4000 + DRAGON_BREATH: 500 + TURTLE_SCUTE: 120 + ARMADILLO_SCUTE: 60 + HONEYCOMB: 40 + SADDLE: 300 + NAME_TAG: 400 + APPLE: 25 + MELON: 35 + SWEET_BERRIES: 10 + GLOW_BERRIES: 30 + COCOA_BEANS: 20 + BAMBOO: 5 + CACTUS: 10 + BROWN_MUSHROOM: 15 + RED_MUSHROOM: 15 + SEA_PICKLE: 25 + SPONGE: 200 + NETHER_WART: 30 + CLAY_BALL: 8 + SNOWBALL: 3 + DIRT: 2 + OBSIDIAN: 80 + NETHERRACK: 3 + SOUL_SAND: 15 + END_STONE: 20 + GLOWSTONE_DUST: 40 + MAGMA_CREAM: 90 OAK_BOAT: 200 OAK_CHEST_BOAT: 600 - # (the full default table ships in code; add or override entries here) + # This is the COMPLETE table. BentoBox REPLACES map settings from config + # rather than merging them, so an entry deleted here is a good that can no + # longer be sold at all - not a fallback to the built-in price. border: # Show island boundaries as particle curtains: the warp-offer ring at the # protection edge (red - where the warp dialog fires) and the edge of island diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 7a5b91f..57dee69 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -42,6 +42,14 @@ tradewinds: parameters: "[number]" unknown: "No island with that number - run /twadmin islands first." teleported: "Arrived at [name]." + priceaudit: + description: "audit price coverage: what can be sold, what is salvage, what is unsellable" + header: "Price audit:" + trade-goods: "Priced trade goods: [number]" + salvage: "Priced salvage: [number]" + unpriceable: "Unpriceable (carryable but unsellable): [number]" + written: "Full report written to [file]" + write-failed: "Could not write the report - see the console." customs: description: "show what customs make of you here: contraband aboard, scan odds, patrol" header: "Customs report:" diff --git a/src/test/java/world/bentobox/tradewinds/SettingsTest.java b/src/test/java/world/bentobox/tradewinds/SettingsTest.java index 4085de1..f994816 100644 --- a/src/test/java/world/bentobox/tradewinds/SettingsTest.java +++ b/src/test/java/world/bentobox/tradewinds/SettingsTest.java @@ -101,6 +101,37 @@ void testBandPolicies() { assertTrue(settings.isNavigationBossbar()); } + @Test + void testEveryPricedMaterialIsReal() { + // A typo or a Minecraft rename (SCUTE -> TURTLE_SCUTE) would silently + // make a good unsellable rather than fail anything + for (String name : settings.getBasePrices().keySet()) { + assertTrue(Material.matchMaterial(name) != null, "Not a material: " + name); + } + for (String name : settings.getFuelValues().keySet()) { + assertTrue(Material.matchMaterial(name) != null, "Not a fuel material: " + name); + } + } + + @Test + void testShippedConfigCarriesEveryPrice() throws Exception { + // BentoBox REPLACES map settings from config.yml instead of merging them + // (YamlDatabaseHandler.deserializeMap), so a price missing from the + // shipped config is a good that cannot be sold at all - it does not fall + // back to the built-in table. The shipped config was 27 of 112 entries, + // which is most of why scavenged goods looked untradeable. + var config = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration( + new java.io.InputStreamReader( + getClass().getClassLoader().getResourceAsStream("config.yml"))); + var section = config.getConfigurationSection("economy.base-prices"); + assertTrue(section != null, "config.yml has no economy.base-prices"); + for (String name : settings.getBasePrices().keySet()) { + assertTrue(section.contains(name), "config.yml is missing a base price for " + name); + } + assertEquals(settings.getBasePrices().size(), section.getKeys(false).size(), + "config.yml and the code default table have drifted"); + } + @Test void testGameplayGates() { assertTrue(settings.isIllegalTradeEnabled()); diff --git a/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java b/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java new file mode 100644 index 0000000..66310e4 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java @@ -0,0 +1,115 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.Material; +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.TradeWinds; +import world.bentobox.tradewinds.dataobjects.IslandDataManager; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.IslandType; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * The salvage economy (Stage 7.5 Phase 2): scavenged goods sell, but at a + * discount and into a stock pool of their own. + * + * @author tastybento + */ +class SalvagePricingTest extends CommonTestSetup { + + private TradeWinds addon; + private Settings settings; + private MarketService service; + + /** A fishing port, which neither produces nor demands metals or salvage. */ + private final IslandSpec port = new IslandSpec(0, 0, 2500, 2500, IslandType.FISHING, SecurityBand.SAFE, + "minecraft:plains", "Test", 4); + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + when(addon.getIslandDataManager()).thenReturn(mock(IslandDataManager.class)); + service = new MarketService(addon); + } + + @Test + void testScavengedGoodsAreSellableAtAll() { + // The complaint that started this work: a scavenger's haul had no price + // and could only be destroyed. Mob drops have no recipe, so these can + // only ever come from the base price table. + for (Material loot : new Material[] { Material.ROTTEN_FLESH, Material.BONE, Material.GUNPOWDER, + Material.ENDER_PEARL, Material.FEATHER, Material.STRING, Material.RABBIT_FOOT, + Material.PHANTOM_MEMBRANE, Material.NAUTILUS_SHELL }) { + assertTrue(service.basePrice(loot).isPresent(), loot + " cannot be priced"); + assertTrue(service.playerSellsAt(port, loot).orElse(0.0) > 0, loot + " sells for nothing"); + } + } + + @Test + void testSalvageIsDiscountedAgainstBook() { + double discounted = service.playerSellsAt(port, Material.ENDER_PEARL).orElseThrow(); + // Flip the discount off and the same pearl fetches book price - which + // isolates the discount from band, tech and drift entirely + settings.setSalvageDiscount(1.0); + double full = service.playerSellsAt(port, Material.ENDER_PEARL).orElseThrow(); + assertTrue(discounted < full, "Salvage must pay less than book: " + discounted + " vs " + full); + assertEquals(0.375, discounted / full, 0.02); + } + + @Test + void testTheDiscountNeverTouchesRealCargo() { + // The regression that would quietly wreck the trade game: legitimate + // cargo must price identically however the salvage knob is set + double before = service.playerSellsAt(port, Material.IRON_INGOT).orElseThrow(); + double buyBefore = service.playerBuysAt(port, Material.WHEAT).orElseThrow(); + settings.setSalvageDiscount(0.05); + assertEquals(before, service.playerSellsAt(port, Material.IRON_INGOT).orElseThrow()); + assertEquals(buyBefore, service.playerBuysAt(port, Material.WHEAT).orElseThrow()); + } + + @Test + void testSalvageDriftsInItsOwnPool() { + // Dumping junk must not crater the same category's honest cargo: a + // rotten flesh sale is STONE to nobody and METALS to nobody + assertEquals(TradeCategory.SALVAGE, service.driftPool(Material.ROTTEN_FLESH)); + assertEquals(TradeCategory.SALVAGE, service.driftPool(Material.DIRT)); + assertEquals(TradeCategory.SALVAGE, service.driftPool(Material.ENDER_PEARL)); + // While real cargo keeps its own pool + assertEquals(TradeCategory.METALS, service.driftPool(Material.IRON_INGOT)); + assertEquals(TradeCategory.CROPS, service.driftPool(Material.WHEAT)); + assertEquals(TradeCategory.GEMS, service.driftPool(Material.DIAMOND)); + } + + @Test + void testTradeGoodsAndSalvageAreDisjoint() { + assertTrue(service.isTradeGood(Material.IRON_INGOT)); + assertTrue(service.isTradeGood(Material.BREAD)); + assertFalse(service.isTradeGood(Material.ROTTEN_FLESH)); + assertFalse(service.isTradeGood(Material.DIRT)); + } + + @Test + void testSalvageStillCannotBeArbitraged() { + // Whatever the discount does to the level, the spread must stay open or + // a port becomes a money printer + for (double discount : new double[] { 0.05, 0.375, 1.0 }) { + settings.setSalvageDiscount(discount); + double buy = service.playerBuysAt(port, Material.ENDER_PEARL).orElseThrow(); + double sell = service.playerSellsAt(port, Material.ENDER_PEARL).orElseThrow(); + assertTrue(sell < buy, "Round trip profits at discount " + discount + ": " + sell + " >= " + buy); + } + } +} diff --git a/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java b/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java index 7862638..714457c 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/TradeCategoryTest.java @@ -35,6 +35,46 @@ void testClassification() { assertEquals(TradeCategory.MISC, TradeCategory.of(Material.DIRT)); } + @Test + void testTradeGoodsAreTheShelves() { + // Salvage is defined by the shelves, not by category name, so that the + // existing trade economy is untouched: an iron ingot is cargo wherever + // it always was, and a rabbit's foot was never on anyone's manifest + assertTrue(TypeEconomy.tradeGoods().contains(Material.IRON_INGOT)); + assertTrue(TypeEconomy.tradeGoods().contains(Material.WHEAT)); + assertTrue(TypeEconomy.tradeGoods().contains(Material.DIAMOND)); + // Outfitter stock counts - a smith who sells iron swords will buy one back + assertTrue(TypeEconomy.tradeGoods().contains(Material.IRON_SWORD)); + assertTrue(TypeEconomy.tradeGoods().contains(Material.FISHING_ROD)); + // The universal essentials + assertTrue(TypeEconomy.tradeGoods().contains(Material.BREAD)); + assertTrue(TypeEconomy.tradeGoods().contains(Material.CHARCOAL)); + // Scavenged loot is not + assertFalse(TypeEconomy.tradeGoods().contains(Material.RABBIT_FOOT)); + assertFalse(TypeEconomy.tradeGoods().contains(Material.ROTTEN_FLESH)); + assertFalse(TypeEconomy.tradeGoods().contains(Material.DIRT)); + assertFalse(TypeEconomy.tradeGoods().contains(Material.ENDER_PEARL)); + } + + @Test + void testSalvageIsItsOwnPoolAndNobodyTradesIt() { + // No island type produces or demands salvage - that is what makes it + // neutral, and what keeps it out of the real categories' stock pools + for (IslandType type : IslandType.values()) { + assertFalse(TypeEconomy.produces(type).contains(TradeCategory.SALVAGE)); + assertFalse(TypeEconomy.demands(type).contains(TradeCategory.SALVAGE)); + } + // And it is tech-neutral: neither raw nor finished + assertFalse(TradeCategory.SALVAGE.isRaw()); + assertFalse(TradeCategory.SALVAGE.isFinished()); + assertTrue(TradeCategory.SALVAGE.isSalvage()); + // of() never returns it: name heuristics do not decide salvage + for (Material material : new Material[] { Material.DIRT, Material.ROTTEN_FLESH, Material.RABBIT_FOOT, + Material.DIAMOND, Material.WHEAT }) { + assertFalse(TradeCategory.of(material).isSalvage(), material + " classified as salvage by name"); + } + } + @Test void testEveryTypeDemandsSomething() { for (IslandType type : IslandType.values()) { From 98c70e8b1fdf1e2cbef97d7769c370b529d3eb6d Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:25:54 -0700 Subject: [PATCH 080/112] Gate salvage by a port's tech level (Stage 7.5 Phase 3) economy.salvage-value-per-tech-level (1500) caps what a single salvage item can be worth for a port to deal in it at all. A TL1 hamlet takes mob loot but not diamond-grade gear; a TL7 hub takes anything. That gives loot a destination, and a destination is a voyage. One correction to the plan while implementing it. Gating on value alone would have had a low-tech LUXURY island refusing the very diamonds it demands, since gems are demanded there but are not on its produce shelf, so a flat cap catches them - which reads as a broken market rather than as a tech gate. The gate now exempts recognised trade goods: a port always deals in what its shelves stock, whatever its tech. Tested explicitly, because it is the sort of thing that looks fine until someone sails to a poor luxury port with a pocket of gems. 267 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 5 +++ docs/PROGRESS.md | 17 +++++++++ .../world/bentobox/tradewinds/Settings.java | 11 ++++++ .../tradewinds/economy/MarketService.java | 29 +++++++++++++++ .../tradewinds/economy/TradeDialog.java | 4 ++ src/main/resources/config.yml | 6 +++ src/main/resources/locales/en-US.yml | 1 + .../economy/SalvagePricingTest.java | 37 +++++++++++++++++++ 8 files changed, 110 insertions(+) diff --git a/TESTING.md b/TESTING.md index 49d449a..fb4a9c0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -76,6 +76,11 @@ noise. - [ ] `/twadmin priceaudit` writes `price-audit.txt` to the addon folder. Read the UNPRICEABLE list: anything a player could plausibly acquire and carry wants a base price adding. +- [ ] **A backwater refuses treasure.** Carry something rich and exotic (totem, + nether star) into a low-tech port: it is absent from the sell page, and + selling it by any other route says the port has no use for it. The same + item sells at a high-tech island. Check a *low-tech luxury* island still + buys diamonds - trade goods are exempt from this gate. - [ ] **Dumping junk does not move honest cargo.** Note a port's iron price, sell it a boatload of dirt and rotten flesh, and check the iron price is unchanged - salvage drifts in its own pool. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9cd00d0..98dd442 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,23 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 7.5 Phase 3 — tech gates what a port will handle (2026-08-03) + +`economy.salvage-value-per-tech-level` (1500) caps the value of a single salvage +item a port will deal in: a TL1 hamlet takes mob loot but not diamond-grade gear, +a TL7 hub takes anything. Loot therefore has a *destination*, and a destination +is a voyage. Refused at the counter with `tradewinds.trade.too-advanced`, and +hidden from the sell page the same way contraband already is. + +**Design correction made during implementation.** The plan said to gate on value +by tech, full stop. Applied to everything that would have had a low-tech LUXURY +island refusing the very diamonds it demands - gems are demanded by LUXURY but +are not on its produce shelf, so a flat cap catches them. The gate therefore +exempts recognised trade goods entirely: a port always deals in what its shelves +stock, whatever its tech. That keeps the existing trade economy untouched (again) +and leaves the gate doing only the job it was added for, which is turning exotic +loot into a reason to sail somewhere. + ## Stage 7.5 Phase 2 — salvage has a market (2026-08-03) Scavenged loot is now sellable, at a discount, into a stock pool of its own. diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index b915c01..7c380f5 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -646,6 +646,15 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "player.max-restarts") private int maxRestarts = 3; + @ConfigComment("How much value a port will handle in a SINGLE salvage item, per tech level:") + @ConfigComment("a TL1 fishing hamlet has no use for a diamond sword and nobody there could") + @ConfigComment("pay for one, while a TL7 industrial hub will take anything. This is what gives") + @ConfigComment("loot a DESTINATION - and a destination is a voyage. Recognised trade goods are") + @ConfigComment("exempt: a port always deals in what its own shelves stock, whatever its tech.") + @ConfigComment("0 disables the gate.") + @ConfigEntry(path = "economy.salvage-value-per-tech-level") + private double salvageValuePerTechLevel = 1500.0; + @ConfigComment("What a port pays for SALVAGE - anything that is not a recognised trade good") @ConfigComment("on some island's shelves: mob drops, worn gear, odd blocks. A fraction of book") @ConfigComment("price. Keep this well under 1: if scavenging and piracy pay as well as trading,") @@ -3078,6 +3087,8 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setContrabandMaterials(List contrabandMaterials) { this.contrabandMaterials = contrabandMaterials; } public int getMaxRestarts() { return maxRestarts; } public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } + public double getSalvageValuePerTechLevel() { return salvageValuePerTechLevel; } + public void setSalvageValuePerTechLevel(double v) { this.salvageValuePerTechLevel = v; } public double getSalvageDiscount() { return salvageDiscount; } public void setSalvageDiscount(double salvageDiscount) { this.salvageDiscount = salvageDiscount; } public Map getBasePrices() { return basePrices; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 1965ea9..f6fa775 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -235,6 +235,28 @@ public TradeCategory driftPool(Material material) { return isTradeGood(material) ? TradeCategory.of(material) : TradeCategory.SALVAGE; } + /** + * Whether this port is developed enough to deal in a single item of this + * value at all. A TL1 fishing hamlet has no use for a diamond sword and + * nobody there could pay for one; a TL7 hub will take anything. This is what + * gives loot a destination, and a destination is a voyage. + *

+ * Recognised trade goods are exempt. A port must always deal in what its own + * shelves stock, or a low-tech luxury island would refuse the very gems it + * demands - which reads as a broken market, not as a tech gate. + * + * @param spec the island + * @param material the material + * @return true if the port will handle it + */ + public boolean handlesValue(IslandSpec spec, Material material) { + double perLevel = addon.getSettings().getSalvageValuePerTechLevel(); + if (perLevel <= 0 || isTradeGood(material)) { + return true; + } + return basePrice(material).orElse(0.0) <= perLevel * spec.techLevel(); + } + private double factor(IslandSpec spec, Material material) { TradeCategory category = TradeCategory.of(material); boolean contraband = addon.getCustomsService() != null @@ -289,6 +311,13 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { thud(player); return 0; } + // Too rich for this port to handle - take it somewhere more developed + if (!handlesValue(spec, material)) { + User.getInstance(player).sendMessage("tradewinds.trade.too-advanced", "[material]", pretty(material), + "[name]", spec.name()); + thud(player); + return 0; + } int count = Math.min(addon.getHoldService().count(player, material), amount); if (count <= 0) { User.getInstance(player).sendMessage("tradewinds.trade.nothing-of-that"); diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index ce0a8c9..547fb6a 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -349,6 +349,10 @@ List sellOffers(Player player, IslandSpec spec) { && !addon.getCustomsService().buysContraband(spec.band())) { continue; } + // Nor for goods too rich for this port's tech to handle + if (!addon.getMarketService().handlesValue(spec, entry.getKey())) { + continue; + } addon.getMarketService().playerSellsAt(spec, entry.getKey()) .ifPresent(unit -> offers.add(new SellOffer(entry.getKey(), entry.getValue(), unit, PriceModel.round2(unit * entry.getValue())))); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 2b9b803..e281edf 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -290,6 +290,12 @@ economy: # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. unstamped-sellables: - SUGAR + # How much value a port will handle in a SINGLE salvage item, per tech level: a + # TL1 fishing hamlet has no use for a diamond sword and nobody there could pay + # for one, while a TL7 hub will take anything. This is what gives loot a + # DESTINATION - and a destination is a voyage. Recognised trade goods are exempt: + # a port always deals in what its own shelves stock. 0 disables the gate. + salvage-value-per-tech-level: 1500.0 # What a port pays for SALVAGE - anything not a recognised trade good on some # island's shelves: mob drops, worn gear, odd blocks. A fraction of book price. # Keep well under 1: if scavenging and piracy pay as well as trading they replace diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 57dee69..3527ec5 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -286,6 +286,7 @@ tradewinds: boat-not-sold-here: "This yard cannot sell you that boat." boat-crafted: "You fit out the [material] - [slots] cargo slots. Your old boat is broken up; your cargo is aboard." craft-smaller-refused: "That boat is no bigger than yours - you would be burning good wood to shrink your hold." + too-advanced: "[name] has no use for [material] - nobody here could pay what it is worth. Try a more developed port." contraband-refused: "The trader looks at what you are offering, then at you. 'Not here. Try a rougher port.'" barred: "The market closes as you approach. 'We know what you are. Take it somewhere lawless.'" nothing-to-sell: "Nothing in your hold that this island will pay for." diff --git a/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java b/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java index 66310e4..adc7962 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java @@ -101,6 +101,43 @@ void testTradeGoodsAndSalvageAreDisjoint() { assertFalse(service.isTradeGood(Material.DIRT)); } + @Test + void testTechGatesWhatAPortWillHandle() { + // 1500/level: a TL1 hamlet takes mob loot but not diamond-grade gear + IslandSpec hamlet = new IslandSpec(0, 0, 2500, 2500, IslandType.FISHING, SecurityBand.SAFE, + "minecraft:plains", "Hamlet", 1); + IslandSpec hub = new IslandSpec(0, 0, 2500, 2500, IslandType.INDUSTRIAL, SecurityBand.SAFE, + "minecraft:plains", "Hub", 7); + assertTrue(service.handlesValue(hamlet, Material.ENDER_PEARL)); + assertTrue(service.handlesValue(hamlet, Material.BONE)); + assertFalse(service.handlesValue(hamlet, Material.NETHER_STAR), "TL1 cannot handle a nether star"); + assertFalse(service.handlesValue(hamlet, Material.TOTEM_OF_UNDYING)); + // The hub takes the lot - loot has a destination, and that is a voyage + assertTrue(service.handlesValue(hub, Material.NETHER_STAR)); + assertTrue(service.handlesValue(hub, Material.TOTEM_OF_UNDYING)); + } + + @Test + void testTheTechGateNeverRefusesAPortsOwnStock() { + // A low-tech LUXURY island DEMANDS gems. If the value gate applied to + // trade goods it would refuse the very diamonds it wants, which reads as + // a broken market rather than as a tech gate. + IslandSpec poorLuxury = new IslandSpec(0, 0, 2500, 2500, IslandType.LUXURY, SecurityBand.SAFE, + "minecraft:plains", "Faded Grandeur", 1); + assertTrue(service.handlesValue(poorLuxury, Material.DIAMOND)); + assertTrue(service.handlesValue(poorLuxury, Material.GOLDEN_APPLE)); + assertTrue(service.playerSellsAt(poorLuxury, Material.DIAMOND).orElseThrow() > 0); + } + + @Test + void testTheGateCanBeSwitchedOff() { + IslandSpec hamlet = new IslandSpec(0, 0, 2500, 2500, IslandType.FISHING, SecurityBand.SAFE, + "minecraft:plains", "Hamlet", 1); + assertFalse(service.handlesValue(hamlet, Material.NETHER_STAR)); + settings.setSalvageValuePerTechLevel(0); + assertTrue(service.handlesValue(hamlet, Material.NETHER_STAR)); + } + @Test void testSalvageStillCannotBeArbitraged() { // Whatever the discount does to the level, the spread must stay open or From c6777e8d816734589f618c03a4cce045e212a68f Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:38:28 -0700 Subject: [PATCH 081/112] Carry NBT in the hold (Stage 7.5 Phase 4) The hold stores one ItemStack per slot instead of material to amount, so a worn bow, a mint bow and a Silk Touch pick are three goods rather than one. Fuel stays material-keyed, because a lump of coal really is a lump of coal. All the slot arithmetic moved to travel/CargoStore as statics, which is what makes it testable, and HoldService keeps its Material-based API as thin overloads - so the buy path, fuel and starter kit needed no changes at all. PriceEngine now applies enchantments through getEnchantmentValue(), which had been written, documented and never called: until now there was never an enchanted item left in the hold to price. Potions are priced by what is in them rather than sharing one Material with a water bottle. Four MockBukkit facts, probed rather than assumed, since "ItemStack meta does not work" turned out to be too coarse a warning: isSimilar, getMaxStackSize and clone all work. But new ItemStack() attaches a phantom UNSPECIFIC meta that clone() drops, so a cloned plain item stops being similar to an identical one - which would have made every sale find nothing aboard. CargoStore.copyOf therefore rebuilds plain items from their type and only clones items carrying real meta; that is better production code regardless, and behaves the same in both worlds. new ItemStack(Material.AIR) throws AbstractMethodError, so air is only ever detected, never manufactured. And serialisation is dead headlessly, so persistence of enchanted cargo is a manual check - recorded in TESTING.md rather than left implied. Also worth knowing: Maven does not recompile unchanged test sources, so the renames surfaced as runtime NoSuchMethodError rather than compile errors. 284 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 12 + docs/PROGRESS.md | 46 +++ .../world/bentobox/tradewinds/Settings.java | 18 ++ .../tradewinds/dataobjects/BoatHold.java | 33 +- .../tradewinds/economy/MarketService.java | 61 +++- .../tradewinds/economy/PriceEngine.java | 43 ++- .../tradewinds/economy/TradeDialog.java | 53 +++- .../tradewinds/travel/BoatListener.java | 30 +- .../tradewinds/travel/CargoStore.java | 197 ++++++++++++ .../bentobox/tradewinds/travel/HoldGui.java | 27 +- .../tradewinds/travel/HoldService.java | 287 +++++++++--------- src/main/resources/config.yml | 9 + .../economy/PriceEngineNbtTest.java | 139 +++++++++ .../tradewinds/travel/BoatOwnershipTest.java | 23 +- .../tradewinds/travel/CargoStoreTest.java | 127 ++++++++ .../tradewinds/travel/HoldServiceTest.java | 19 +- 16 files changed, 901 insertions(+), 223 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/travel/CargoStore.java create mode 100644 src/test/java/world/bentobox/tradewinds/economy/PriceEngineNbtTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/CargoStoreTest.java diff --git a/TESTING.md b/TESTING.md index fb4a9c0..7f94f9c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,6 +70,18 @@ noise. - [ ] Warp: you and the boat arrive facing the **dock**; hold forward and you reach it. - [ ] Sell the cargo at the new island at a different price than you paid. +- [ ] **NBT survives the hold, and a restart.** Load an enchanted, a renamed and + a half-broken tool into the hold. Each takes its own slot and keeps its + name, enchantments and damage bar. Restart the server: all three are still + exactly what they were. (This cannot be unit-tested - ItemStack + serialisation does not work headlessly.) +- [ ] **Condition and enchantments move the price.** A worn tool sells for + visibly less than a mint one; a Silk Touch pick for more than a plain one; + a Potion of Strength II for far more than a water bottle. Two differently + enchanted swords appear as two separate rows on the sell page. +- [ ] **The TNT destroys what you picked.** With both a plain and an enchanted + sword aboard, select the enchanted one and destroy it: the plain one is + still there. - [ ] **Scavenged loot sells.** Kill a few mobs on an islet, load bones, string, rotten flesh and gunpowder into the hold, and sell at any port: every one of them has a price, visibly lower than a trade good of similar book value. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 98dd442..4786e25 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,52 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 7.5 Phase 4 — the hold carries NBT (2026-08-03) + +The hold stores **one ItemStack per slot** instead of material→amount, so a worn +bow, a mint bow and a Silk Touch pick are three different goods. + +- `BoatHold.contents` -> `cargo` as `List`; `expanders` as + `List>`. **Fuel stays material-keyed** - a lump of coal is a + lump of coal. BentoBox's `ItemStackTypeAdapter` (registered in + `BentoboxTypeAdapterFactory`) persists these through Bukkit's own YAML + serializer, so meta survives a restart; its 99-amount clamp cannot be hit + because a slot never holds more than a stack. +- New `travel/CargoStore` holds all the slot arithmetic - capacity, consolidation, + drain, compaction - as statics, which is what makes it testable. +- `HoldService` keeps its Material-based API as thin overloads over the + ItemStack ones, so the buy path, fuel and starter kit were untouched. +- `MarketService.basePrice/playerBuysAt/playerSellsAt/sell/handlesValue` now take + ItemStacks. The sell page groups by *distinct good*, so two differently + enchanted swords are two rows at two prices. +- `PriceEngine` applies durability (already written), **enchantments** ( + `getEnchantmentValue` existed and had never been called - there was never an + enchanted item left to price) and **potions** via `PotionMeta`. New knobs + `economy.enchantment-price-factor` 0.05 and `economy.potion-effect-price` 150. +- `HoldGui.Selection` carries the ItemStack, not the Material - otherwise + selecting an enchanted sword and hitting the TNT destroyed a plain one. + +**MockBukkit findings that shaped the design** (probed rather than assumed - the +CLAUDE.md note that "ItemStack meta does not work" was too coarse): +- `isSimilar`, `getMaxStackSize`, `clone` and `equals` all work. +- `new ItemStack(...)` attaches a phantom **UNSPECIFIC** meta and `clone()` drops + it, so a cloned plain item stops being `isSimilar` to an identical one. Hence + `CargoStore.copyOf` rebuilds plain items from their type and only clones items + with real meta - which is better production code anyway, and gives the same + answer in both worlds. `isPlain` compares against a pristine stack rather than + trusting `hasItemMeta()`, which lies here. +- `new ItemStack(Material.AIR)` throws `AbstractMethodError`: never manufacture + an air stack, only detect one. +- ItemStack **serialisation is dead** headlessly (`craftDelegate` is null), so + persistence of enchanted cargo is a manual check in `TESTING.md`. Potion + pricing is likewise manual-only - `PotionType`'s effect registry is not + available in tests. + +**Pitfall:** Maven does not recompile unchanged test sources, so four tests +failed at *runtime* with `NoSuchMethodError` against the new signatures rather +than failing to compile. If a rename looks like it broke tests mysteriously, +that is what happened. + ## Stage 7.5 Phase 3 — tech gates what a port will handle (2026-08-03) `economy.salvage-value-per-tech-level` (1500) caps the value of a single salvage diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 7c380f5..9674523 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -646,6 +646,17 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "player.max-restarts") private int maxRestarts = 3; + @ConfigComment("Enchantment premium: an item's price is multiplied by (1 + quality x this),") + @ConfigComment("where quality weights each enchantment by usefulness and level. 0 ignores") + @ConfigComment("enchantments, so a Silk Touch pick sells as a plain one.") + @ConfigEntry(path = "economy.enchantment-price-factor") + private double enchantmentPriceFactor = 0.05; + + @ConfigComment("Coins added per potion effect, times (1 + amplifier). Every potion shares one") + @ConfigComment("Material, so without this a Potion of Strength II is priced as a water bottle.") + @ConfigEntry(path = "economy.potion-effect-price") + private double potionEffectPrice = 150.0; + @ConfigComment("How much value a port will handle in a SINGLE salvage item, per tech level:") @ConfigComment("a TL1 fishing hamlet has no use for a diamond sword and nobody there could") @ConfigComment("pay for one, while a TL7 industrial hub will take anything. This is what gives") @@ -704,6 +715,9 @@ private static Map defaultBasePrices() { map.put("TOTEM_OF_UNDYING", 4000.0); map.put("DRAGON_BREATH", 500.0); map.put("TURTLE_SCUTE", 120.0); map.put("ARMADILLO_SCUTE", 60.0); map.put("HONEYCOMB", 40.0); map.put("SADDLE", 300.0); map.put("NAME_TAG", 400.0); + // Potions: the bottle is nearly worthless; what is IN it is priced by + // effect (see economy.potion-effect-price) + map.put("POTION", 60.0); map.put("SPLASH_POTION", 80.0); map.put("LINGERING_POTION", 120.0); // Foraged and gathered: apple and melon drop from leaves and vines, the // rest are picked, dug or fished up map.put("APPLE", 25.0); map.put("MELON", 35.0); map.put("SWEET_BERRIES", 10.0); @@ -3087,6 +3101,10 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setContrabandMaterials(List contrabandMaterials) { this.contrabandMaterials = contrabandMaterials; } public int getMaxRestarts() { return maxRestarts; } public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } + public double getEnchantmentPriceFactor() { return enchantmentPriceFactor; } + public void setEnchantmentPriceFactor(double v) { this.enchantmentPriceFactor = v; } + public double getPotionEffectPrice() { return potionEffectPrice; } + public void setPotionEffectPrice(double v) { this.potionEffectPrice = v; } public double getSalvageValuePerTechLevel() { return salvageValuePerTechLevel; } public void setSalvageValuePerTechLevel(double v) { this.salvageValuePerTechLevel = v; } public double getSalvageDiscount() { return salvageDiscount; } diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/BoatHold.java b/src/main/java/world/bentobox/tradewinds/dataobjects/BoatHold.java index f80a61f..fe94223 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/BoatHold.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/BoatHold.java @@ -5,6 +5,8 @@ import java.util.List; import java.util.Map; +import org.bukkit.inventory.ItemStack; + import com.google.gson.annotations.Expose; import world.bentobox.bentobox.database.objects.DataObject; @@ -40,14 +42,25 @@ public class BoatHold implements DataObject { @Expose private String owner = ""; + /** + * Cargo, one stack per occupied slot, exactly like a real inventory. + *

+ * A list of stacks rather than material→amount because a worn bow, a mint + * bow and a Silk Touch pick are not the same good and must not merge. + * BentoBox's {@code ItemStackTypeAdapter} persists these through Bukkit's + * own YAML serializer, so enchantments, damage and potion data all survive + * a restart. Note the adapter clamps a stack to 99, which one-stack-per-slot + * can never exceed. + */ @Expose - private Map contents = new LinkedHashMap<>(); + private List cargo = new ArrayList<>(); @Expose private Map fuel = new LinkedHashMap<>(); + /** Installed cargo expanders, each its own slot-per-stack store. */ @Expose - private List> expanders = new ArrayList<>(); + private List> expanders = new ArrayList<>(); /** Last known position, so the chart can point at it from anywhere. */ @Expose @@ -108,12 +121,12 @@ public boolean isUnowned() { return owner == null || owner.isEmpty(); } - public Map getContents() { - return contents; + public List getCargo() { + return cargo; } - public void setContents(Map contents) { - this.contents = contents; + public void setCargo(List cargo) { + this.cargo = cargo == null ? new ArrayList<>() : cargo; } public Map getFuel() { @@ -124,12 +137,12 @@ public void setFuel(Map fuel) { this.fuel = fuel; } - public List> getExpanders() { + public List> getExpanders() { return expanders; } - public void setExpanders(List> expanders) { - this.expanders = expanders; + public void setExpanders(List> expanders) { + this.expanders = expanders == null ? new ArrayList<>() : expanders; } public String getWorld() { @@ -176,6 +189,6 @@ public void setExpiresAt(long expiresAt) { * @return true if this boat carries nothing at all */ public boolean isEmpty() { - return contents.isEmpty() && fuel.isEmpty() && expanders.isEmpty(); + return cargo.isEmpty() && fuel.isEmpty() && expanders.isEmpty(); } } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index f6fa775..dab20aa 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -126,25 +126,40 @@ public PriceModel model() { * Base price of a material: configured, or derived from crafting recipes * by the embedded price engine. Empty means not tradeable. */ - public Optional basePrice(Material material) { - double price = priceEngine.getPrice(new ItemStack(material)); + public Optional basePrice(ItemStack item) { + double price = priceEngine.getPrice(item); return price > 0 ? Optional.of(price) : Optional.empty(); } + public Optional basePrice(Material material) { + return basePrice(new ItemStack(material)); + } + /** - * What a player pays this island per unit, or empty if not tradeable. + * What a player pays this island per unit of this exact item, or empty if + * not tradeable. The ITEM matters, not just its type: a worn bow and a mint + * one are different goods at the counter. */ + public Optional playerBuysAt(IslandSpec spec, ItemStack item) { + return basePrice(item).map(base -> model().playerBuysAt(base * contrabandPremium(item.getType()), + factor(spec, item.getType()))); + } + public Optional playerBuysAt(IslandSpec spec, Material material) { - return basePrice(material) - .map(base -> model().playerBuysAt(base * contrabandPremium(material), factor(spec, material))); + return playerBuysAt(spec, new ItemStack(material)); } /** - * What this island pays a player per unit, or empty if not tradeable. + * What this island pays a player per unit of this exact item, or empty if + * not tradeable. */ + public Optional playerSellsAt(IslandSpec spec, ItemStack item) { + return basePrice(item).map(base -> model().playerSellsAt(base * contrabandPremium(item.getType()), + factor(spec, item.getType()))); + } + public Optional playerSellsAt(IslandSpec spec, Material material) { - return basePrice(material) - .map(base -> model().playerSellsAt(base * contrabandPremium(material), factor(spec, material))); + return playerSellsAt(spec, new ItemStack(material)); } /** @@ -249,12 +264,18 @@ public TradeCategory driftPool(Material material) { * @param material the material * @return true if the port will handle it */ - public boolean handlesValue(IslandSpec spec, Material material) { + public boolean handlesValue(IslandSpec spec, ItemStack item) { double perLevel = addon.getSettings().getSalvageValuePerTechLevel(); - if (perLevel <= 0 || isTradeGood(material)) { + if (perLevel <= 0 || isTradeGood(item.getType())) { return true; } - return basePrice(material).orElse(0.0) <= perLevel * spec.techLevel(); + // The ITEM's value, so an enchanted sword can outgrow a port its plain + // twin would have been welcome at + return basePrice(item).orElse(0.0) <= perLevel * spec.techLevel(); + } + + public boolean handlesValue(IslandSpec spec, Material material) { + return handlesValue(spec, new ItemStack(material)); } private double factor(IslandSpec spec, Material material) { @@ -293,12 +314,22 @@ private double factor(IslandSpec spec, Material material) { * @return amount sold */ public int sell(Player player, IslandSpec spec, Material material, int amount) { + return sell(player, spec, new ItemStack(material), amount); + } + + /** + * Sell up to {@code amount} of one exact item from the hold. + * + * @return amount sold + */ + public int sell(Player player, IslandSpec spec, ItemStack item, int amount) { + Material material = item.getType(); if (!boatIsHere(player, spec)) { User.getInstance(player).sendMessage("tradewinds.trade.boat-not-here"); thud(player); return 0; } - Optional unitPrice = playerSellsAt(spec, material); + Optional unitPrice = playerSellsAt(spec, item); Optional vault = addon.getPlugin().getVault(); if (unitPrice.isEmpty() || vault.isEmpty() || amount <= 0) { return 0; @@ -312,13 +343,13 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { return 0; } // Too rich for this port to handle - take it somewhere more developed - if (!handlesValue(spec, material)) { + if (!handlesValue(spec, item)) { User.getInstance(player).sendMessage("tradewinds.trade.too-advanced", "[material]", pretty(material), "[name]", spec.name()); thud(player); return 0; } - int count = Math.min(addon.getHoldService().count(player, material), amount); + int count = Math.min(addon.getHoldService().count(player, item), amount); if (count <= 0) { User.getInstance(player).sendMessage("tradewinds.trade.nothing-of-that"); thud(player); @@ -329,7 +360,7 @@ public int sell(Player player, IslandSpec spec, Material material, int amount) { if (event.isCancelled()) { return 0; } - int removed = addon.getHoldService().remove(player, material, count); + int removed = addon.getHoldService().remove(player, item, count); double total = PriceModel.round2(removed * unitPrice.get()); vault.get().deposit(User.getInstance(player), total); // Drift moves on the VALUE of the trade, not the item count: a port that diff --git a/src/main/java/world/bentobox/tradewinds/economy/PriceEngine.java b/src/main/java/world/bentobox/tradewinds/economy/PriceEngine.java index d2be622..1a97dc9 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/PriceEngine.java +++ b/src/main/java/world/bentobox/tradewinds/economy/PriceEngine.java @@ -12,6 +12,8 @@ import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.CookingRecipe; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.potion.PotionEffect; import org.bukkit.inventory.Recipe; import org.bukkit.inventory.ShapedRecipe; import org.bukkit.inventory.ShapelessRecipe; @@ -82,7 +84,46 @@ public double getPrice(ItemStack item) { if (base < 0) { return base; } - return applyDurability(item, base); + // NBT is part of the price (Stage 7.5 Phase 4): a worn bow is worth less + // than a mint one, a Silk Touch pick more than a plain one, and a Potion + // of Strength II is not a bottle of water + return applyPotion(item, applyEnchantments(item, applyDurability(item, base))); + } + + /** + * Enchantments as a price premium. {@link #getEnchantmentValue} was written + * long before anything called it - the hold used to discard NBT on the way + * in, so there was never an enchanted item left to price. + */ + private double applyEnchantments(ItemStack item, double base) { + double factor = addon.getSettings().getEnchantmentPriceFactor(); + if (factor <= 0) { + return base; + } + return base * (1.0 + getEnchantmentValue(item) * factor); + } + + /** + * Potions by what is actually in them. Every potion shares one Material, so + * without this a Potion of Strength II and a bottle of water are the same + * good - which is the clearest case for pricing NBT at all. + */ + private double applyPotion(ItemStack item, double base) { + double perEffect = addon.getSettings().getPotionEffectPrice(); + if (perEffect <= 0 || !(item.getItemMeta() instanceof PotionMeta potion)) { + return base; + } + double value = 0.0; + // The base type carries the everyday brews; custom effects are stacked on + if (potion.getBasePotionType() != null) { + for (PotionEffect effect : potion.getBasePotionType().getPotionEffects()) { + value += perEffect * (1 + effect.getAmplifier()); + } + } + for (PotionEffect effect : potion.getCustomEffects()) { + value += perEffect * (1 + effect.getAmplifier()); + } + return base + value; } /** diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 547fb6a..3cf040c 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -7,6 +7,7 @@ import org.bukkit.Material; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import io.papermc.paper.dialog.Dialog; import io.papermc.paper.registry.data.dialog.ActionButton; @@ -134,13 +135,13 @@ public void openSell(Player player, IslandSpec spec) { Component each = ui(player, "market.sell-tooltip-each", "[price]", Money.format(addon, offer.unitPrice())); buttons.add(button(ui(player, "market.sell-one", "[material]", name), each, - () -> sellThenReopen(player, spec, offer.material(), 1))); + () -> sellThenReopen(player, spec, offer.item(), 1))); buttons.add(button(ui(player, "market.sell-batch", "[material]", name, "[amount]", String.valueOf(MID_BATCH)), each, - () -> sellThenReopen(player, spec, offer.material(), MID_BATCH))); + () -> sellThenReopen(player, spec, offer.item(), MID_BATCH))); buttons.add(button(ui(player, "market.sell-all", "[amount]", String.valueOf(offer.amount()), "[price]", Money.format(addon, offer.total())), each, - () -> sellThenReopen(player, spec, offer.material(), Integer.MAX_VALUE))); + () -> sellThenReopen(player, spec, offer.item(), Integer.MAX_VALUE))); } List body = new ArrayList<>(List.of(ui(player, "market.selling-body", NO_VARS), statusLine(player))); @@ -151,8 +152,8 @@ public void openSell(Player player, IslandSpec spec) { backButton(player, spec), 3); } - private void sellThenReopen(Player player, IslandSpec spec, Material material, int amount) { - addon.getMarketService().sell(player, spec, material, amount); + private void sellThenReopen(Player player, IslandSpec spec, ItemStack item, int amount) { + addon.getMarketService().sell(player, spec, item, amount); openSell(player, spec); } @@ -334,32 +335,58 @@ public void confirmBoatFound(Player player, String taking, String leaving, Strin * One sellable line: what the hold has and what this island pays. Package * visible for tests. */ - record SellOffer(Material material, int amount, double unitPrice, double total) { + record SellOffer(ItemStack item, int amount, double unitPrice, double total) { + + Material material() { + return item.getType(); + } } List sellOffers(Player player, IslandSpec spec) { List offers = new ArrayList<>(); - // No stamps: everything aboard (expanders included) is sellable - // wherever a price and the port's own rules (contraband bands) allow - for (Map.Entry entry : addon.getHoldService().tradeContents(player).entrySet()) { + // No stamps: everything aboard (expanders included) is sellable wherever + // a price and the port's own rules (contraband bands) allow + for (Map.Entry entry : distinctGoods(player).entrySet()) { + ItemStack item = entry.getKey(); // Do not quote a price for something this port will refuse at the // counter: the sell page used to offer a dollar for contraband that // a safe island would then decline, which reads as a broken market - if (addon.getCustomsService() != null && addon.getCustomsService().isContraband(entry.getKey()) + if (addon.getCustomsService() != null && addon.getCustomsService().isContraband(item.getType()) && !addon.getCustomsService().buysContraband(spec.band())) { continue; } // Nor for goods too rich for this port's tech to handle - if (!addon.getMarketService().handlesValue(spec, entry.getKey())) { + if (!addon.getMarketService().handlesValue(spec, item)) { continue; } - addon.getMarketService().playerSellsAt(spec, entry.getKey()) - .ifPresent(unit -> offers.add(new SellOffer(entry.getKey(), entry.getValue(), unit, + addon.getMarketService().playerSellsAt(spec, item) + .ifPresent(unit -> offers.add(new SellOffer(item, entry.getValue(), unit, PriceModel.round2(unit * entry.getValue())))); } return offers; } + /** + * Everything aboard, one entry per DISTINCT good with its total count. Two + * differently enchanted swords are two entries - they are worth different + * money and must be sellable separately - while cargo split across several + * slots collapses back into one line. + */ + private java.util.Map distinctGoods(Player player) { + java.util.Map goods = new java.util.LinkedHashMap<>(); + for (ItemStack stack : addon.getHoldService().tradeCargo(player)) { + ItemStack key = goods.keySet().stream() + .filter(seen -> world.bentobox.tradewinds.travel.CargoStore.stacksTogether(seen, stack)) + .findFirst().orElse(null); + if (key == null) { + goods.put(stack, stack.getAmount()); + } else { + goods.merge(key, stack.getAmount(), Integer::sum); + } + } + return goods; + } + /** * Balance and hold space - shown on every market screen so traders always * know what they can afford and what they can carry. diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java index 510abb3..ba1697b 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java @@ -436,7 +436,8 @@ private void offerFoundBoat(Player player, BoatHold hold, Runnable onTake) { onTake.run(); return; } - int carried = hold.getContents().values().stream().mapToInt(Integer::intValue).sum() + int carried = hold.getCargo().stream().filter(java.util.Objects::nonNull) + .mapToInt(ItemStack::getAmount).sum() + hold.getFuel().values().stream().mapToInt(Integer::intValue).sum(); String cargo = carried > 0 ? String.valueOf(carried) : null; Runnable onTransfer = carried > 0 && ownBoatWithinReach(player) ? () -> { @@ -477,24 +478,19 @@ boolean ownBoatWithinReach(Player player) { */ int salvage(Player player, BoatHold wreck) { int moved = 0; - java.util.List> entries = new java.util.ArrayList<>( - wreck.getContents().entrySet()); - entries.sort(java.util.Comparator.comparingDouble((Map.Entry e) -> { - Material m = Material.matchMaterial(e.getKey()); - return m == null ? 0 : addon.getMarketService().basePrice(m).orElse(0.0); - }).reversed()); - for (Map.Entry entry : entries) { - Material material = Material.matchMaterial(entry.getKey()); - if (material == null) { - wreck.getContents().remove(entry.getKey()); - continue; - } - int added = addon.getHoldService().add(player, material, entry.getValue()); + // Most valuable first: with limited slots, a partial rescue should save + // the cargo worth saving + CargoStore.compact(wreck.getCargo()); + java.util.List stacks = new java.util.ArrayList<>(wreck.getCargo()); + stacks.sort(java.util.Comparator.comparingDouble( + (ItemStack stack) -> addon.getMarketService().basePrice(stack).orElse(0.0)).reversed()); + for (ItemStack stack : stacks) { + int added = addon.getHoldService().add(player, stack, stack.getAmount()); moved += added; - if (added >= entry.getValue()) { - wreck.getContents().remove(entry.getKey()); + if (added >= stack.getAmount()) { + wreck.getCargo().remove(stack); } else if (added > 0) { - wreck.getContents().put(entry.getKey(), entry.getValue() - added); + stack.setAmount(stack.getAmount() - added); } } for (Map.Entry entry : new java.util.ArrayList<>(wreck.getFuel().entrySet())) { diff --git a/src/main/java/world/bentobox/tradewinds/travel/CargoStore.java b/src/main/java/world/bentobox/tradewinds/travel/CargoStore.java new file mode 100644 index 0000000..3ca2628 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/CargoStore.java @@ -0,0 +1,197 @@ +package world.bentobox.tradewinds.travel; + +import java.util.List; + +import org.bukkit.inventory.ItemStack; + +/** + * Slot arithmetic for a hold: how much fits, what stacks with what, and where + * goods land. A store is a mutable list of stacks, one entry per occupied + * slot, exactly like a real inventory - so a unique item takes a slot of its + * own and identical items consolidate. + *

+ * Cargo is a list of {@link ItemStack} rather than material→amount because a + * worn bow, a mint bow and a Silk Touch pick are not the same good, and a map + * keyed by material said they were. + *

+ * Everything here is static and side-effect-free except for the explicit + * mutations, which is what makes the arithmetic testable instead of buried in + * {@link HoldService}. ItemStack serialisation does not work headlessly + * (the CraftBukkit delegate is absent), so persistence is a manual check - but + * the packing, stacking and capacity rules are all covered by unit tests. + * + * @author tastybento + */ +public final class CargoStore { + + private CargoStore() { + // Static use only + } + + /** + * Whether two stacks consolidate. {@link ItemStack#isSimilar} is the + * authority: it ignores amount and compares everything else, so enchanted, + * renamed and damaged items each keep their own slot. + */ + public static boolean stacksTogether(ItemStack a, ItemStack b) { + return a != null && b != null && a.isSimilar(b); + } + + /** + * The stack size limit for an item, never below 1. + */ + public static int maxStack(ItemStack like) { + return Math.max(1, like.getMaxStackSize()); + } + + /** + * Whether an item carries nothing but its type - no enchantments, no name, + * no damage, nothing worth preserving. + *

+ * Compared against a pristine stack of the same type rather than by asking + * {@code hasItemMeta()}, which is not trustworthy: MockBukkit attaches a + * phantom UNSPECIFIC meta to every freshly built stack and then drops it on + * {@code clone()}, so a cloned plain item stops being "similar" to an + * identical one. Comparing against a pristine reference gives the same + * answer in both worlds. + * + * @param like the item + * @return true if a plain {@code new ItemStack(type, amount)} would do + */ + public static boolean isPlain(ItemStack like) { + return like.isSimilar(new ItemStack(like.getType())); + } + + /** + * A copy of an item at a given amount, preserving anything that makes it + * distinctive. Plain goods are rebuilt from their type - there is nothing to + * carry, and it avoids {@code clone()} entirely - while enchanted, renamed or + * damaged items are cloned so their NBT survives. + * + * @param like the item to copy + * @param amount the amount the copy should hold + * @return the copy + */ + public static ItemStack copyOf(ItemStack like, int amount) { + if (isPlain(like)) { + return new ItemStack(like.getType(), amount); + } + ItemStack copy = like.clone(); + copy.setAmount(amount); + return copy; + } + + /** + * How many of this item the store already holds. + */ + public static int count(List store, ItemStack like) { + int total = 0; + for (ItemStack held : store) { + if (stacksTogether(held, like)) { + total += held.getAmount(); + } + } + return total; + } + + /** + * How many MORE of this item fit: the headroom in stacks it consolidates + * with, plus a full stack for every free slot. + * + * @param store the store + * @param slotBudget total slots this store may occupy + * @param like the item + * @return how many more fit + */ + public static int capacityFor(List store, int slotBudget, ItemStack like) { + int max = maxStack(like); + int headroom = 0; + for (ItemStack held : store) { + if (stacksTogether(held, like)) { + headroom += Math.max(0, max - held.getAmount()); + } + } + int freeSlots = Math.max(0, slotBudget - store.size()); + return headroom + freeSlots * max; + } + + /** + * Add up to {@code amount} of an item, topping up existing stacks before + * opening new slots. + * + * @param store the store, mutated + * @param slotBudget total slots this store may occupy + * @param like the item to add (not mutated; copies are stored) + * @param amount how many to add + * @return how many were actually added + */ + public static int added(List store, int slotBudget, ItemStack like, int amount) { + if (like == null || like.getType().isAir() || amount <= 0) { + return 0; + } + int max = maxStack(like); + int left = amount; + // Top up what is already aboard first, so cargo stays consolidated + for (ItemStack held : store) { + if (left <= 0) { + break; + } + if (stacksTogether(held, like)) { + int room = Math.max(0, max - held.getAmount()); + int fit = Math.min(room, left); + if (fit > 0) { + held.setAmount(held.getAmount() + fit); + left -= fit; + } + } + } + // Then open new slots + while (left > 0 && store.size() < slotBudget) { + int fit = Math.min(max, left); + store.add(copyOf(like, fit)); + left -= fit; + } + return amount - left; + } + + /** + * Remove up to {@code amount} of an item, emptying partial stacks first so + * the store does not fragment. + * + * @param store the store, mutated + * @param like the item to remove + * @param amount how many to remove + * @return how many were actually removed + */ + public static int removed(List store, ItemStack like, int amount) { + if (like == null || amount <= 0) { + return 0; + } + int left = amount; + var iterator = store.iterator(); + while (iterator.hasNext() && left > 0) { + ItemStack held = iterator.next(); + if (!stacksTogether(held, like)) { + continue; + } + int taken = Math.min(held.getAmount(), left); + left -= taken; + if (taken >= held.getAmount()) { + iterator.remove(); + } else { + held.setAmount(held.getAmount() - taken); + } + } + return amount - left; + } + + /** + * Drop null and zero-amount entries - a store should never render an empty + * slot as an occupied one. + * + * @param store the store, mutated + */ + public static void compact(List store) { + store.removeIf(held -> held == null || held.getType().isAir() || held.getAmount() <= 0); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java index 0a11206..b7c9924 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java @@ -78,7 +78,7 @@ public class HoldGui implements Listener { * A selection waiting for the TNT: a cargo stack (material + amount) or * an empty expander (expanderIndex >= 0, material null). */ - private record Selection(Material material, int amount, int expanderIndex) { + private record Selection(ItemStack item, int amount, int expanderIndex) { } /** A nested expander window. */ @@ -130,7 +130,7 @@ private void render(Player player, Inventory inv) { } inv.setItem(TNT_SLOT, tnt(user)); // Cargo: consolidated stacks, then installed expanders, then space - List stacks = stacksOf(hold.contents(id)); + List stacks = hold.cargo(id); int expanders = hold.expanderCount(id); int capacity = hold.capacitySlots(player); for (int i = 0; i < CARGO.length; i++) { @@ -162,7 +162,7 @@ private void renderNested(Player player, Inventory inv, int index) { inv.setItem(slot, border); } inv.setItem(TNT_SLOT, tnt(user)); - List stacks = stacksOf(hold.expanderContents(player.getUniqueId(), index)); + List stacks = hold.expanderCargo(player.getUniqueId(), index); for (int i = 0; i < HoldService.EXPANDER_SLOTS; i++) { inv.setItem(CARGO[i], i < stacks.size() ? stacks.get(i) : null); } @@ -190,7 +190,8 @@ private ItemStack expanderItem(User user, UUID id, int index) { } /** - * A contents map as max-size stacks, display order preserved. + * A FUEL map as max-size stacks, display order preserved. Cargo no longer + * needs this - it is already stored one stack per slot. */ private static List stacksOf(Map contents) { List stacks = new ArrayList<>(); @@ -430,12 +431,12 @@ private void topClick(Player player, int slot, ItemStack shown, ClickType click) selected.remove(id); return; } - int stackCount = stacksOf(hold.contents(id)).size(); + int stackCount = hold.cargo(id).size(); if (index >= stackCount && index < stackCount + hold.expanderCount(id)) { int expander = index - stackCount; if (click == ClickType.RIGHT) { // Select an EMPTY expander for the TNT - if (hold.expanderContents(id, expander).isEmpty()) { + if (hold.expanderCargo(id, expander).isEmpty()) { selected.put(id, new Selection(null, 0, expander)); user.sendMessage("tradewinds.hold.expander-selected", "[number]", String.valueOf(expander + 1)); @@ -459,7 +460,7 @@ private void topClick(Player player, int slot, ItemStack shown, ClickType click) moveCargoFuel(player, shown); return; } - selected.put(id, new Selection(shown.getType(), shown.getAmount(), -1)); + selected.put(id, new Selection(shown, shown.getAmount(), -1)); user.sendMessage("tradewinds.hold.selected", "[amount]", String.valueOf(shown.getAmount()), "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); } @@ -482,14 +483,14 @@ private void nestedTopClick(Player player, int slot, ItemStack shown, int index) UUID id = player.getUniqueId(); if (slot == TNT_SLOT) { Selection selection = nestedSelected.remove(id); - if (selection == null || selection.material() == null) { + if (selection == null || selection.item() == null) { user.sendMessage("tradewinds.hold.select-first"); return; } - int destroyed = addon.getHoldService().removeFromExpander(id, index, selection.material(), + int destroyed = addon.getHoldService().removeFromExpander(id, index, selection.item(), selection.amount()); user.sendMessage("tradewinds.hold.destroyed", "[amount]", String.valueOf(destroyed), "[material]", - world.bentobox.tradewinds.economy.PriceEngine.prettify(selection.material().name())); + world.bentobox.tradewinds.economy.PriceEngine.prettify(selection.item().getType().name())); player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 0.4f, 1.4f); return; } @@ -502,7 +503,7 @@ private void nestedTopClick(Player player, int slot, ItemStack shown, int index) moveCargoFuel(player, shown); return; } - nestedSelected.put(id, new Selection(shown.getType(), shown.getAmount(), -1)); + nestedSelected.put(id, new Selection(shown, shown.getAmount(), -1)); user.sendMessage("tradewinds.hold.selected", "[amount]", String.valueOf(shown.getAmount()), "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); } @@ -523,9 +524,9 @@ private void destroySelection(Player player, Selection selection) { } return; } - int destroyed = addon.getHoldService().remove(player, selection.material(), selection.amount()); + int destroyed = addon.getHoldService().remove(player, selection.item(), selection.amount()); user.sendMessage("tradewinds.hold.destroyed", "[amount]", String.valueOf(destroyed), "[material]", - world.bentobox.tradewinds.economy.PriceEngine.prettify(selection.material().name())); + world.bentobox.tradewinds.economy.PriceEngine.prettify(selection.item().getType().name())); player.playSound(player.getLocation(), Sound.ENTITY_GENERIC_EXPLODE, 0.4f, 1.4f); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index 81be38f..071a38a 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -1,5 +1,6 @@ package world.bentobox.tradewinds.travel; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -9,6 +10,7 @@ import org.bukkit.Material; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.dataobjects.BoatHold; @@ -20,11 +22,15 @@ * is no hold at all, and a two-slot raft carries two slots however rich its * captain used to be. *

- * Contents are virtual: material → amount in the database, auto-consolidated, - * so no inventory trick can extract them. One-way rule: anything non-container - * can be added; cargo leaves only by being sold or destroyed. Fuel is exempt - - * it moves freely both ways, and fuel-valued cargo can be shifted into the - * fuel row. + * Contents are virtual: a list of stacks in the database, one per occupied + * slot, so no inventory trick can extract them. Cargo carries its full identity + * - a worn bow, a mint bow and a Silk Touch pick are three different goods - + * with the slot arithmetic in {@link CargoStore}. Fuel stays material→amount + * because fuel is genuinely fungible: a lump of coal is a lump of coal. + *

+ * One-way rule: anything non-container can be added; cargo leaves only by being + * sold or destroyed. Fuel is exempt - it moves freely both ways, and fuel-valued + * cargo can be shifted into the fuel row. * * @author tastybento */ @@ -86,79 +92,66 @@ public int capacitySlots(Player player) { // ----------------------------------------------------------------- cargo /** - * Cargo contents of a boat, materials in display order. + * The main hold's cargo stacks, in slot order. Live references: mutate only + * through this service so the record gets saved. */ - public static Map contentsOf(BoatHold hold) { - Map result = new LinkedHashMap<>(); - hold.getContents().forEach((name, amount) -> { - Material material = Material.matchMaterial(name); - if (material != null && amount != null && amount > 0) { - result.put(material, amount); - } - }); - return result; + public static List cargoOf(BoatHold hold) { + CargoStore.compact(hold.getCargo()); + return hold.getCargo(); } - public Map contents(Player player) { - return contents(player.getUniqueId()); + public List cargo(UUID playerId) { + return active(playerId).map(HoldService::cargoOf).orElseGet(ArrayList::new); } - public Map contents(UUID playerId) { - return active(playerId).map(HoldService::contentsOf).orElseGet(LinkedHashMap::new); + public List cargo(Player player) { + return cargo(player.getUniqueId()); } /** - * How many of one material the whole ship carries, expanders included. + * Everything aboard for trade purposes: main cargo plus every expander, as + * distinct stacks. Two enchanted swords with different enchantments appear + * separately, because they are worth different money. */ - public int count(Player player, Material material) { - return active(player.getUniqueId()).map(hold -> { - int total = hold.getContents().getOrDefault(material.name(), 0); - for (Map expander : hold.getExpanders()) { - total += expander.getOrDefault(material.name(), 0); - } - return total; - }).orElse(0); + public List tradeCargo(Player player) { + List all = new ArrayList<>(cargo(player.getUniqueId())); + active(player.getUniqueId()).ifPresent(hold -> hold.getExpanders().forEach(expander -> { + CargoStore.compact(expander); + all.addAll(expander); + })); + return all; } /** - * Everything aboard for trade purposes: main cargo plus every expander. + * How many matching items the whole ship carries, expanders included. */ - public Map tradeContents(Player player) { - Map result = contents(player.getUniqueId()); - active(player.getUniqueId()).ifPresent(hold -> hold.getExpanders().forEach(expander -> - expander.forEach((name, amount) -> { - Material material = Material.matchMaterial(name); - if (material != null && amount != null && amount > 0) { - result.merge(material, amount, Integer::sum); - } - }))); - return result; + public int count(Player player, ItemStack like) { + return active(player.getUniqueId()).map(hold -> { + int total = CargoStore.count(hold.getCargo(), like); + for (List expander : hold.getExpanders()) { + total += CargoStore.count(expander, like); + } + return total; + }).orElse(0); } /** - * Slots one amount of a material occupies, consolidated. + * Material convenience: how many plain items of this type are aboard. */ - public static int slotsFor(Material material, int amount) { - int max = Math.max(1, material.getMaxStackSize()); - return (amount + max - 1) / max; + public int count(Player player, Material material) { + return count(player, new ItemStack(material)); } /** - * Cargo slots in use: consolidated stacks plus one per installed expander. + * Cargo slots in use: one per stack, plus one per installed expander. */ public int slotsUsed(UUID playerId) { return active(playerId).map(HoldService::slotsUsedIn).orElse(0); } static int slotsUsedIn(BoatHold hold) { - int used = hold.getExpanders().size(); - for (Map.Entry entry : hold.getContents().entrySet()) { - Material material = Material.matchMaterial(entry.getKey()); - if (material != null && entry.getValue() != null && entry.getValue() > 0) { - used += slotsFor(material, entry.getValue()); - } - } - return used; + CargoStore.compact(hold.getCargo()); + return hold.getExpanders().size() + hold.getCargo().size(); } public int slotsFree(UUID playerId) { @@ -169,43 +162,30 @@ public int slotsFree(UUID playerId) { * How many MORE of a material fit anywhere aboard: the main hold's free * slots and headroom, plus every installed expander's. */ - public int capacityFor(UUID playerId, Material material) { - if (refuses(material)) { + public int capacityFor(UUID playerId, ItemStack like) { + if (like == null || refuses(like.getType())) { return 0; } return active(playerId).map(hold -> { - int total = mainCapacityFor(hold, capacitySlots(playerId), material); - for (Map expander : hold.getExpanders()) { - total += storeCapacityFor(expander, EXPANDER_SLOTS, material); + int total = mainCapacityFor(hold, capacitySlots(playerId), like); + for (List expander : hold.getExpanders()) { + total += CargoStore.capacityFor(expander, EXPANDER_SLOTS, like); } return total; }).orElse(0); } - private static int mainCapacityFor(BoatHold hold, int capacitySlots, Material material) { - int max = Math.max(1, material.getMaxStackSize()); - int current = hold.getContents().getOrDefault(material.name(), 0); - int headroom = current == 0 ? 0 : slotsFor(material, current) * max - current; - int free = Math.max(0, capacitySlots - slotsUsedIn(hold)); - return free * max + headroom; + public int capacityFor(UUID playerId, Material material) { + return capacityFor(playerId, new ItemStack(material)); } /** - * Room in one bounded store (an expander's contents map). + * Room in the main hold. The expanders each occupy a cargo slot, so the + * budget for loose stacks is the boat's capacity less the expander count. */ - private static int storeCapacityFor(Map store, int slotBudget, Material material) { - int used = 0; - for (Map.Entry entry : store.entrySet()) { - Material m = Material.matchMaterial(entry.getKey()); - if (m != null && entry.getValue() != null && entry.getValue() > 0) { - used += slotsFor(m, entry.getValue()); - } - } - int free = Math.max(0, slotBudget - used); - int max = Math.max(1, material.getMaxStackSize()); - int current = store.getOrDefault(material.name(), 0); - int headroom = current == 0 ? 0 : slotsFor(material, current) * max - current; - return free * max + headroom; + private static int mainCapacityFor(BoatHold hold, int capacitySlots, ItemStack like) { + int budget = Math.max(0, capacitySlots - hold.getExpanders().size()); + return CargoStore.capacityFor(hold.getCargo(), budget, like); } /** @@ -223,9 +203,13 @@ public boolean refuses(Material material) { * * @return how many were actually added */ - public int add(Player player, Material material, int amount) { + public int add(Player player, ItemStack like, int amount) { return active(player.getUniqueId()) - .map(hold -> addTo(hold, capacitySlots(player.getUniqueId()), material, amount)).orElse(0); + .map(hold -> addTo(hold, capacitySlots(player.getUniqueId()), like, amount)).orElse(0); + } + + public int add(Player player, Material material, int amount) { + return add(player, new ItemStack(material), amount); } /** @@ -233,25 +217,17 @@ public int add(Player player, Material material, int amount) { * * @return how many were actually added */ - public int addTo(BoatHold hold, int capacitySlots, Material material, int amount) { - if (refuses(material) || amount <= 0) { + public int addTo(BoatHold hold, int capacitySlots, ItemStack like, int amount) { + if (like == null || refuses(like.getType()) || amount <= 0) { return 0; } - int left = amount; - int main = Math.min(left, mainCapacityFor(hold, capacitySlots, material)); - if (main > 0) { - hold.getContents().merge(material.name(), main, Integer::sum); - left -= main; - } - for (Map expander : hold.getExpanders()) { + int budget = Math.max(0, capacitySlots - hold.getExpanders().size()); + int left = amount - CargoStore.added(hold.getCargo(), budget, like, amount); + for (List expander : hold.getExpanders()) { if (left <= 0) { break; } - int fit = Math.min(left, storeCapacityFor(expander, EXPANDER_SLOTS, material)); - if (fit > 0) { - expander.merge(material.name(), fit, Integer::sum); - left -= fit; - } + left -= CargoStore.added(expander, EXPANDER_SLOTS, like, left); } int added = amount - left; if (added > 0) { @@ -260,27 +236,34 @@ public int addTo(BoatHold hold, int capacitySlots, Material material, int amount return added; } + public int addTo(BoatHold hold, int capacitySlots, Material material, int amount) { + return addTo(hold, capacitySlots, new ItemStack(material), amount); + } + /** * Remove cargo - ONLY the market (sell) and the TNT slot (destroy) may * call this; there is no path from here to a player inventory. * * @return how many were actually removed */ + public int remove(Player player, ItemStack like, int amount) { + return active(player.getUniqueId()).map(hold -> removeFrom(hold, like, amount)).orElse(0); + } + public int remove(Player player, Material material, int amount) { - return active(player.getUniqueId()).map(hold -> removeFrom(hold, material, amount)).orElse(0); + return remove(player, new ItemStack(material), amount); } /** * Remove cargo from a specific boat. */ - public int removeFrom(BoatHold hold, Material material, int amount) { - int left = amount; - left -= drain(hold.getContents(), material, left); - for (Map expander : hold.getExpanders()) { + public int removeFrom(BoatHold hold, ItemStack like, int amount) { + int left = amount - CargoStore.removed(hold.getCargo(), like, amount); + for (List expander : hold.getExpanders()) { if (left <= 0) { break; } - left -= drain(expander, material, left); + left -= CargoStore.removed(expander, like, left); } int taken = amount - left; if (taken > 0) { @@ -289,18 +272,8 @@ public int removeFrom(BoatHold hold, Material material, int amount) { return taken; } - private static int drain(Map store, Material material, int amount) { - int current = store.getOrDefault(material.name(), 0); - int taken = Math.min(current, amount); - if (taken <= 0) { - return 0; - } - if (taken == current) { - store.remove(material.name()); - } else { - store.put(material.name(), current - taken); - } - return taken; + public int removeFrom(BoatHold hold, Material material, int amount) { + return removeFrom(hold, new ItemStack(material), amount); } /** @@ -322,7 +295,7 @@ public int moveCargoToFuel(Player player, Material material, int amount) { if (fit <= 0) { return 0; } - int taken = remove(player, material, fit); + int taken = remove(player, new ItemStack(material), fit); if (taken <= 0) { return 0; } @@ -350,7 +323,7 @@ public boolean installExpander(UUID playerId) { return false; } return active(playerId).map(hold -> { - hold.getExpanders().add(new LinkedHashMap<>()); + hold.getExpanders().add(new ArrayList<>()); addon.getHoldManager().save(hold); return true; }).orElse(false); @@ -364,49 +337,48 @@ public boolean expandersOpenable(UUID playerId) { return boat(playerId) == Material.PALE_OAK_CHEST_BOAT; } - public Map expanderContents(UUID playerId, int index) { - Map result = new LinkedHashMap<>(); - active(playerId).ifPresent(hold -> { - List> expanders = hold.getExpanders(); - if (index >= 0 && index < expanders.size()) { - expanders.get(index).forEach((name, amount) -> { - Material material = Material.matchMaterial(name); - if (material != null && amount != null && amount > 0) { - result.put(material, amount); - } - }); + /** + * One expander's cargo stacks, in slot order. + */ + public List expanderCargo(UUID playerId, int index) { + return active(playerId).map(hold -> { + List> expanders = hold.getExpanders(); + if (index < 0 || index >= expanders.size()) { + return new ArrayList(); } - }); - return result; + CargoStore.compact(expanders.get(index)); + return new ArrayList<>(expanders.get(index)); + }).orElseGet(ArrayList::new); } - public int addToExpander(Player player, int index, Material material, int amount) { - if (refuses(material) || amount <= 0) { + public int addToExpander(Player player, int index, ItemStack like, int amount) { + if (like == null || refuses(like.getType()) || amount <= 0) { return 0; } return active(player.getUniqueId()).map(hold -> { - List> expanders = hold.getExpanders(); + List> expanders = hold.getExpanders(); if (index < 0 || index >= expanders.size()) { return 0; } - Map store = expanders.get(index); - int fit = Math.min(amount, storeCapacityFor(store, EXPANDER_SLOTS, material)); - if (fit <= 0) { - return 0; + int fit = CargoStore.added(expanders.get(index), EXPANDER_SLOTS, like, amount); + if (fit > 0) { + addon.getHoldManager().save(hold); } - store.merge(material.name(), fit, Integer::sum); - addon.getHoldManager().save(hold); return fit; }).orElse(0); } - public int removeFromExpander(UUID playerId, int index, Material material, int amount) { + public int addToExpander(Player player, int index, Material material, int amount) { + return addToExpander(player, index, new ItemStack(material), amount); + } + + public int removeFromExpander(UUID playerId, int index, ItemStack like, int amount) { return active(playerId).map(hold -> { - List> expanders = hold.getExpanders(); + List> expanders = hold.getExpanders(); if (index < 0 || index >= expanders.size()) { return 0; } - int taken = drain(expanders.get(index), material, amount); + int taken = CargoStore.removed(expanders.get(index), like, amount); if (taken > 0) { addon.getHoldManager().save(hold); } @@ -414,13 +386,21 @@ public int removeFromExpander(UUID playerId, int index, Material material, int a }).orElse(0); } + public int removeFromExpander(UUID playerId, int index, Material material, int amount) { + return removeFromExpander(playerId, index, new ItemStack(material), amount); + } + /** * Destroy an EMPTY expander (the TNT refuses a loaded one). */ public boolean destroyExpander(UUID playerId, int index) { return active(playerId).map(hold -> { - List> expanders = hold.getExpanders(); - if (index < 0 || index >= expanders.size() || !expanders.get(index).isEmpty()) { + List> expanders = hold.getExpanders(); + if (index < 0 || index >= expanders.size()) { + return false; + } + CargoStore.compact(expanders.get(index)); + if (!expanders.get(index).isEmpty()) { return false; } expanders.remove(index); @@ -446,6 +426,15 @@ public static Map fuelOf(BoatHold hold) { return result; } + /** + * Slots one amount of a material occupies, consolidated. Fuel only - cargo + * counts slots by stack now (see {@link CargoStore}). + */ + public static int slotsFor(Material material, int amount) { + int max = Math.max(1, material.getMaxStackSize()); + return (amount + max - 1) / max; + } + public int fuelSlotsUsed(UUID playerId) { int used = 0; for (Map.Entry entry : fuelContents(playerId).entrySet()) { @@ -501,6 +490,24 @@ public int addFuelTo(BoatHold hold, Material material, int amount) { return fit; } + /** + * Drain a fuel entry. Fuel is material-keyed and fungible, so it keeps the + * simple map arithmetic that cargo has outgrown. + */ + private static int drain(Map store, Material material, int amount) { + int current = store.getOrDefault(material.name(), 0); + int taken = Math.min(current, amount); + if (taken <= 0) { + return 0; + } + if (taken == current) { + store.remove(material.name()); + } else { + store.put(material.name(), current - taken); + } + return taken; + } + /** * Remove fuel - unlike cargo, fuel moves freely back out. */ diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e281edf..09f8209 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -290,6 +290,12 @@ economy: # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. unstamped-sellables: - SUGAR + # Enchantment premium: price is multiplied by (1 + quality x this), where quality + # weights each enchantment by usefulness and level. 0 ignores enchantments. + enchantment-price-factor: 0.05 + # Coins per potion effect, times (1 + amplifier). Every potion shares one + # Material, so without this a Potion of Strength II prices as a water bottle. + potion-effect-price: 150.0 # How much value a port will handle in a SINGLE salvage item, per tech level: a # TL1 fishing hamlet has no use for a diamond sword and nobody there could pay # for one, while a TL7 hub will take anything. This is what gives loot a @@ -394,6 +400,9 @@ economy: HONEYCOMB: 40 SADDLE: 300 NAME_TAG: 400 + POTION: 60 + SPLASH_POTION: 80 + LINGERING_POTION: 120 APPLE: 25 MELON: 35 SWEET_BERRIES: 10 diff --git a/src/test/java/world/bentobox/tradewinds/economy/PriceEngineNbtTest.java b/src/test/java/world/bentobox/tradewinds/economy/PriceEngineNbtTest.java new file mode 100644 index 0000000..903d65f --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/PriceEngineNbtTest.java @@ -0,0 +1,139 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.bukkit.Material; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.ShapelessRecipe; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; +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.TradeWinds; + +/** + * NBT is part of the price (Stage 7.5 Phase 4): a worn bow is worth less than a + * mint one and a Silk Touch pick more than a plain one. + *

+ * ItemStacks are MOCKED here rather than constructed. Under the patched + * MockBukkit a real {@code new ItemStack(...)} gets a phantom UNSPECIFIC meta and + * no working item factory, so damage and enchantments cannot be set on one - the + * only way to exercise the meta paths is to mock the stack and its meta. + * + * @author tastybento + */ +class PriceEngineNbtTest extends CommonTestSetup { + + private static final double SWORD_BASE = 2 * 90.0; + + private TradeWinds addon; + private Settings settings; + private PriceEngine engine; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + // An iron sword is 2 iron ingots (90 each) and a stick + ShapelessRecipe recipe = mock(ShapelessRecipe.class); + when(recipe.getResult()).thenReturn(new ItemStack(Material.IRON_SWORD, 1)); + when(recipe.getIngredientList()).thenReturn(List.of(new ItemStack(Material.IRON_INGOT, 2))); + Function> source = item -> item.getType() == Material.IRON_SWORD + ? List.of(recipe) : List.of(); + engine = new PriceEngine(addon, source); + } + + /** A mocked stack of a real material carrying a mocked meta. */ + private ItemStack stackWith(Material material, ItemMeta meta, Map enchants) { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(material); + when(item.getItemMeta()).thenReturn(meta); + when(item.getEnchantments()).thenReturn(enchants); + return item; + } + + @Test + void testAMintSwordIsWorthItsParts() { + ItemStack mint = stackWith(Material.IRON_SWORD, mock(ItemMeta.class), Map.of()); + assertEquals(SWORD_BASE, engine.getPrice(mint)); + } + + @Test + void testAWornSwordIsWorthLess() { + Damageable half = mock(Damageable.class, org.mockito.Mockito.withSettings() + .extraInterfaces(ItemMeta.class)); + when(half.hasDamage()).thenReturn(true); + // An iron sword has 250 durability; spend half of it + when(half.getDamage()).thenReturn(125); + ItemStack worn = stackWith(Material.IRON_SWORD, (ItemMeta) half, Map.of()); + double price = engine.getPrice(worn); + assertTrue(price < SWORD_BASE, "A worn sword must be worth less: " + price); + assertEquals(SWORD_BASE * (1 - 125.0 / 250.0), price, 0.01); + } + + @Test + void testAnAlmostBrokenSwordIsNearlyWorthless() { + Damageable spent = mock(Damageable.class, org.mockito.Mockito.withSettings() + .extraInterfaces(ItemMeta.class)); + when(spent.hasDamage()).thenReturn(true); + when(spent.getDamage()).thenReturn(249); + ItemStack junk = stackWith(Material.IRON_SWORD, (ItemMeta) spent, Map.of()); + assertTrue(engine.getPrice(junk) < SWORD_BASE * 0.01, + "One hit from breaking should be worth nearly nothing"); + } + + @Test + void testEnchantmentsAddAPremium() { + ItemStack plain = stackWith(Material.IRON_SWORD, mock(ItemMeta.class), Map.of()); + ItemStack sharp = stackWith(Material.IRON_SWORD, mock(ItemMeta.class), + Map.of(Enchantment.SILK_TOUCH, 1)); + double premium = engine.getPrice(sharp); + assertTrue(premium > engine.getPrice(plain), "Silk Touch must be worth something: " + premium); + // Weight 10 x level 1 x factor 0.05 = +50% + assertEquals(SWORD_BASE * 1.5, premium, 0.01); + } + + @Test + void testHigherLevelsAreWorthMore() { + ItemStack one = stackWith(Material.IRON_SWORD, mock(ItemMeta.class), + Map.of(Enchantment.SHARPNESS, 1)); + ItemStack five = stackWith(Material.IRON_SWORD, mock(ItemMeta.class), + Map.of(Enchantment.SHARPNESS, 5)); + assertTrue(engine.getPrice(five) > engine.getPrice(one)); + } + + @Test + void testTheEnchantmentPremiumCanBeSwitchedOff() { + ItemStack sharp = stackWith(Material.IRON_SWORD, mock(ItemMeta.class), + Map.of(Enchantment.SILK_TOUCH, 1)); + settings.setEnchantmentPriceFactor(0); + assertEquals(SWORD_BASE, engine.getPrice(sharp)); + } + + @Test + void testDamageAndEnchantmentsCompound() { + Damageable half = mock(Damageable.class, org.mockito.Mockito.withSettings() + .extraInterfaces(ItemMeta.class)); + when(half.hasDamage()).thenReturn(true); + when(half.getDamage()).thenReturn(125); + ItemStack wornButGood = stackWith(Material.IRON_SWORD, (ItemMeta) half, + Map.of(Enchantment.SILK_TOUCH, 1)); + // Half the durability, half again the enchantment premium on top + assertEquals(SWORD_BASE * 0.5 * 1.5, engine.getPrice(wornButGood), 0.01); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/BoatOwnershipTest.java b/src/test/java/world/bentobox/tradewinds/travel/BoatOwnershipTest.java index 93c8011..46a6862 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/BoatOwnershipTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/BoatOwnershipTest.java @@ -87,23 +87,29 @@ void testUnownedBoatIsNeverProtected() { assertFalse(listener.isProtected(hull, atIsland(100))); } + /** Total of one material in a boat's cargo. */ + private static int cargoCount(BoatHold hold, Material material) { + return hold.getCargo().stream().filter(s -> s != null && s.getType() == material) + .mapToInt(ItemStack::getAmount).sum(); + } + @Test void testTakingABoatAbandonsTheOldOneWithItsCargo() { BoatHold mine = holds.giveBoat(uuid, Material.OAK_BOAT); - mine.getContents().put("DIAMOND", 12); + mine.getCargo().add(new ItemStack(Material.DIAMOND, 12)); BoatHold prize = holds.unownedBoat(Material.CHERRY_CHEST_BOAT); - prize.getContents().put("EMERALD", 5); + prize.getCargo().add(new ItemStack(Material.EMERALD, 5)); holds.manager().setActiveBoat(uuid, prize); // The prize is mine, cargo and all assertEquals(prize.getUniqueId(), holds.manager().activeBoat(uuid).orElseThrow().getUniqueId()); assertEquals(uuid.toString(), prize.getOwner()); - assertEquals(5, (int) prize.getContents().get("EMERALD")); + assertEquals(5, cargoCount(prize, Material.EMERALD)); // The boat I left keeps MY cargo, and loses its owner: unowned, // capturable, and still findable on the chart assertTrue(mine.isUnowned(), "The abandoned boat is unowned"); - assertEquals(12, (int) mine.getContents().get("DIAMOND"), "Its cargo stays aboard"); + assertEquals(12, cargoCount(mine, Material.DIAMOND), "Its cargo stays aboard"); assertEquals(mine.getUniqueId(), holds.manager().oldBoat(uuid).orElseThrow().getUniqueId()); } @@ -171,9 +177,10 @@ void testTakingABoatWhileCarryingOneMergesRatherThanDuplicating() { when(world.getName()).thenReturn("tradewinds_world"); when(addon.getMarketService()) .thenReturn(mock(world.bentobox.tradewinds.economy.MarketService.class)); - when(addon.getMarketService().basePrice(any())).thenReturn(java.util.Optional.of(1.0)); + when(addon.getMarketService().basePrice(any(ItemStack.class))) + .thenReturn(java.util.Optional.of(1.0)); BoatHold carried = holds.giveBoat(uuid, Material.OAK_BOAT); - carried.getContents().put("COD", 20); + carried.getCargo().add(new ItemStack(Material.COD, 20)); carried.setWorld("tradewinds_world"); BoatHold found = holds.unownedBoat(Material.CHERRY_CHEST_BOAT); @@ -209,8 +216,8 @@ void testTakingABoatWhileCarryingOneMergesRatherThanDuplicating() { // The prize is theirs, the cargo came across, and the spare hull is // no longer in the pack assertEquals(found.getUniqueId(), holds.manager().activeBoat(uuid).orElseThrow().getUniqueId()); - assertEquals(20, (int) found.getContents().get("COD"), "Cargo moved into the new hull"); - assertTrue(carried.getContents().isEmpty(), "The old hull was emptied"); + assertEquals(20, cargoCount(found, Material.COD), "Cargo moved into the new hull"); + assertTrue(carried.getCargo().isEmpty(), "The old hull was emptied"); org.mockito.Mockito.verify(spare).setAmount(0); } diff --git a/src/test/java/world/bentobox/tradewinds/travel/CargoStoreTest.java b/src/test/java/world/bentobox/tradewinds/travel/CargoStoreTest.java new file mode 100644 index 0000000..c0f1eb8 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/CargoStoreTest.java @@ -0,0 +1,127 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.CommonTestSetup; + +/** + * The hold's slot arithmetic. This is where the NBT-aware hold is actually + * testable: ItemStack serialisation does not work headlessly, but + * {@code isSimilar}, {@code getMaxStackSize} and {@code clone} all do. + * + * @author tastybento + */ +class CargoStoreTest extends CommonTestSetup { + + private final List store = new ArrayList<>(); + + @Test + void testConsolidatesBeforeOpeningSlots() { + assertEquals(64, CargoStore.added(store, 3, new ItemStack(Material.WHEAT), 64)); + assertEquals(1, store.size()); + // The next 10 top up the existing stack rather than taking a new slot + assertEquals(10, CargoStore.added(store, 3, new ItemStack(Material.WHEAT), 10)); + assertEquals(2, store.size()); + assertEquals(74, CargoStore.count(store, new ItemStack(Material.WHEAT))); + } + + @Test + void testSlotBudgetIsHard() { + // Two slots of wheat is 128 and not a grain more + assertEquals(128, CargoStore.added(store, 2, new ItemStack(Material.WHEAT), 500)); + assertEquals(2, store.size()); + assertEquals(0, CargoStore.added(store, 2, new ItemStack(Material.WHEAT), 1)); + assertEquals(0, CargoStore.added(store, 2, new ItemStack(Material.DIRT), 1)); + } + + @Test + void testCapacityForCountsHeadroomAndFreeSlots() { + CargoStore.added(store, 5, new ItemStack(Material.WHEAT), 100); + // 100 wheat = 2 slots (64 + 36), so 28 headroom + 3 free slots x 64 + assertEquals(28 + 3 * 64, CargoStore.capacityFor(store, 5, new ItemStack(Material.WHEAT))); + // A different item gets no headroom, only the free slots + assertEquals(3 * 64, CargoStore.capacityFor(store, 5, new ItemStack(Material.DIRT))); + // And a full store has room for nothing + CargoStore.added(store, 5, new ItemStack(Material.DIRT), 1000); + assertEquals(0, CargoStore.capacityFor(store, 5, new ItemStack(Material.DIRT))); + } + + @Test + void testUniqueItemsEachTakeASlot() { + // A tool that does not stack occupies one slot per item, which is what + // makes a hold full of scavenged gear behave as players expect + assertEquals(1, CargoStore.added(store, 4, new ItemStack(Material.IRON_SWORD), 1)); + assertEquals(1, CargoStore.added(store, 4, new ItemStack(Material.IRON_SWORD), 1)); + assertEquals(2, store.size()); + assertEquals(1, new ItemStack(Material.IRON_SWORD).getMaxStackSize()); + } + + @Test + void testRemoveEmptiesPartialsAndTidiesUp() { + CargoStore.added(store, 5, new ItemStack(Material.WHEAT), 100); + assertEquals(2, store.size()); + // Taking 64 clears the first stack entirely and leaves no empty slot + assertEquals(64, CargoStore.removed(store, new ItemStack(Material.WHEAT), 64)); + assertEquals(1, store.size()); + assertEquals(36, CargoStore.count(store, new ItemStack(Material.WHEAT))); + // Asking for more than is there takes what there is + assertEquals(36, CargoStore.removed(store, new ItemStack(Material.WHEAT), 500)); + assertTrue(store.isEmpty()); + } + + @Test + void testRemoveOnlyTakesWhatMatches() { + CargoStore.added(store, 5, new ItemStack(Material.WHEAT), 10); + CargoStore.added(store, 5, new ItemStack(Material.DIRT), 10); + assertEquals(0, CargoStore.removed(store, new ItemStack(Material.STONE), 5)); + assertEquals(10, CargoStore.count(store, new ItemStack(Material.WHEAT))); + assertEquals(10, CargoStore.count(store, new ItemStack(Material.DIRT))); + } + + @Test + void testStacksTogetherIsSimilarityNotType() { + assertTrue(CargoStore.stacksTogether(new ItemStack(Material.WHEAT, 1), new ItemStack(Material.WHEAT, 40))); + assertFalse(CargoStore.stacksTogether(new ItemStack(Material.WHEAT), new ItemStack(Material.DIRT))); + assertFalse(CargoStore.stacksTogether(null, new ItemStack(Material.WHEAT))); + } + + @Test + void testNothingIsAddedForNonsense() { + // NB: no AIR ItemStack is constructed anywhere - MockBukkit throws + // AbstractMethodError on new ItemStack(Material.AIR), so air is only ever + // detected on items handed to us, never manufactured + assertEquals(0, CargoStore.added(store, 5, null, 10)); + assertEquals(0, CargoStore.added(store, 5, new ItemStack(Material.WHEAT), 0)); + assertEquals(0, CargoStore.added(store, 5, new ItemStack(Material.WHEAT), -5)); + assertEquals(0, CargoStore.removed(store, null, 5)); + assertTrue(store.isEmpty()); + } + + @Test + void testCompactDropsEmpties() { + store.add(new ItemStack(Material.WHEAT, 5)); + store.add(null); + CargoStore.compact(store); + assertEquals(1, store.size()); + } + + @Test + void testPlainGoodsRoundTripAsThemselves() { + // The trap this design avoids: a stored copy must still consolidate with + // an identical item arriving later, or every sale finds nothing aboard + assertTrue(CargoStore.isPlain(new ItemStack(Material.WHEAT))); + ItemStack stored = CargoStore.copyOf(new ItemStack(Material.WHEAT), 7); + assertEquals(7, stored.getAmount()); + assertTrue(CargoStore.stacksTogether(stored, new ItemStack(Material.WHEAT))); + assertTrue(CargoStore.stacksTogether(new ItemStack(Material.WHEAT), stored)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java index e7e41df..e6c2084 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java @@ -13,6 +13,7 @@ import java.util.UUID; import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -177,6 +178,12 @@ void testExpanderInstallRules() { assertEquals(20, service.slotsFree(uuid)); } + /** Total of one material across a stack list. */ + private static int countOf(java.util.List stacks, Material material) { + return stacks.stream().filter(s -> s != null && s.getType() == material) + .mapToInt(ItemStack::getAmount).sum(); + } + @Test void testExpanderSpilloverAndAggregation() { holds.giveBoat(uuid, Material.PALE_OAK_CHEST_BOAT); @@ -186,13 +193,13 @@ void testExpanderSpilloverAndAggregation() { assertEquals(2624, service.add(mockPlayer, Material.WHEAT, 9999)); // Aggregated count sees everything; main contents map does not assertEquals(2624, service.count(mockPlayer, Material.WHEAT)); - assertEquals(1280, (int) service.contents(uuid).get(Material.WHEAT)); - assertEquals(1344, (int) service.expanderContents(uuid, 0).get(Material.WHEAT)); - assertEquals(2624, (int) service.tradeContents(mockPlayer).get(Material.WHEAT)); + assertEquals(1280, countOf(service.cargo(uuid), Material.WHEAT)); + assertEquals(1344, countOf(service.expanderCargo(uuid, 0), Material.WHEAT)); + assertEquals(2624, countOf(service.tradeCargo(mockPlayer), Material.WHEAT)); // Removal drains the main hold first, then the expander assertEquals(1300, service.remove(mockPlayer, Material.WHEAT, 1300)); - assertEquals(0, service.contents(uuid).getOrDefault(Material.WHEAT, 0)); - assertEquals(1324, (int) service.expanderContents(uuid, 0).get(Material.WHEAT)); + assertEquals(0, countOf(service.cargo(uuid), Material.WHEAT)); + assertEquals(1324, countOf(service.expanderCargo(uuid, 0), Material.WHEAT)); } @Test @@ -215,7 +222,7 @@ void testInertExpandersKeepContents() { // not openable, contents intact and still counted aboard service.active(uuid).orElseThrow().setMaterial(Material.OAK_BOAT.name()); assertFalse(service.expandersOpenable(uuid)); - assertEquals(5, (int) service.expanderContents(uuid, 0).get(Material.DIAMOND)); + assertEquals(5, countOf(service.expanderCargo(uuid, 0), Material.DIAMOND)); assertEquals(5, service.count(mockPlayer, Material.DIAMOND)); } From 305a865fa9be9cdbaa8dbc26791004d61c9aa0e8 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:43:25 -0700 Subject: [PATCH 082/112] Make the hold two-way for your own goods (Stage 7.5 Phase 5) A scavenger has to be able to use their hold as a hold. This narrows a load-bearing rule, so it is written down in CLAUDE.md as well as here: "cargo leaves the hold only by sale or destruction" now applies to trader-bought cargo only. That is the half that matters - it is what makes speculating on a cargo a commitment rather than a warehouse, and what keeps the hold from becoming an off-market channel between players. Provenance is a PDC mark on the item itself, which buys three things at once: it survives the hold's serialisation, it travels with the goods through a boat capture, and it makes marked and unmarked stacks fail isSimilar - so one bought diamond can never lock up twenty mined ones. withdraw() returns -1 rather than 0 for bought cargo so the GUI can explain itself instead of just refusing. Left-click now takes cargo out; the old plain-click "send burnables to the fuel row" moved to shift-click, and right-click selects for the TNT. All three are stated in the border pane's lore rather than left to be discovered. Found while implementing: the existing deposit path called add(player, material, amount), so loading an enchanted sword into the hold stored a plain one. Harmless before Phase 4, silently destructive after it. 287 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 10 ++- TESTING.md | 11 +++ docs/PROGRESS.md | 30 +++++++ .../tradewinds/economy/MarketService.java | 7 +- .../bentobox/tradewinds/travel/CargoMark.java | 71 ++++++++++++++++ .../bentobox/tradewinds/travel/HoldGui.java | 41 +++++++-- .../tradewinds/travel/HoldService.java | 31 +++++++ src/main/resources/locales/en-US.yml | 6 +- .../tradewinds/travel/HoldServiceTest.java | 84 ++++++++++++++++++- 9 files changed, 275 insertions(+), 16 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/travel/CargoMark.java diff --git a/CLAUDE.md b/CLAUDE.md index 24e177b..3b6670e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,8 +40,10 @@ right tier, not to the end; `docs/TESTING-archive.md` is the old per-stage list, history only. Update PROGRESS and TESTING as features land. Load-bearing design rules (from the spec — breaking one is a bug): -trading transacts only against the **virtual hold**; cargo leaves the hold only -by sale or destruction; everything downstream of the galaxy seed is a pure +trading transacts only against the **virtual hold**; **trader-bought** cargo +leaves the hold only by sale or destruction (player-loaded salvage may be +withdrawn — narrowed 2026-08-03, see `tradewinds-salvage-plan.md`, and the +distinction is a PDC mark, `travel/CargoMark`); everything downstream of the galaxy seed is a pure function of (seed, position) with **no Bukkit imports** (package `world.bentobox.tradewinds.galaxy`), unit-tested headlessly; no End world ever; interstice re-engage is always free; police mobs never drop loot. @@ -70,6 +72,10 @@ one-way-cargo rule below to trader-bought cargo only. the previous boat to an unowned OLD BOAT; `clearActiveBoat` just drops the pointer. Taking a boat also strips its former owner — miss that and the victim keeps phantom slots and the login path hands the boat back. +- **Cargo is a list of stacks, one per slot** (`BoatHold.cargo`), not + material→amount: a worn bow, a mint bow and a Silk Touch pick are three goods. + Slot arithmetic lives in `travel/CargoStore`; fuel stays material-keyed because + fuel is fungible. **Match cargo with `isSimilar`, never by material.** - **Money is whole coins.** Buy prices `ceil`, sell prices `floor` — the direction stops the spread closing (nearest-rounding is exploitable). Never format money by hand: `economy.Money.format(addon, amount)` asks the server's diff --git a/TESTING.md b/TESTING.md index 7f94f9c..f70dfb8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,6 +70,17 @@ noise. - [ ] Warp: you and the boat arrive facing the **dock**; hold forward and you reach it. - [ ] Sell the cargo at the new island at a different price than you paid. +- [ ] **The hold works both ways.** Mine cobblestone on an islet, load it into + the hold, then left-click it in the hold window: it comes back to your + inventory. With a full inventory the overflow drops at your feet, never + vanishes. +- [ ] **Bought cargo cannot be withdrawn.** Buy cargo at a market, then try to + left-click it out of the hold: refused, with a message saying it leaves by + sale or by the TNT. Selling it still works. +- [ ] **Bought and mined goods do not merge.** Buy 5 diamonds and mine 20: they + occupy separate slots, and only the mined 20 can be withdrawn. +- [ ] Hold gestures: left-click withdraws, shift-click sends coal to the fuel + row, right-click selects for the TNT. - [ ] **NBT survives the hold, and a restart.** Load an enchanted, a renamed and a half-broken tool into the hold. Each takes its own slot and keeps its name, enchantments and damage bar. Restart the server: all three are still diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4786e25..fe39cdb 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,36 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 7.5 Phase 5 — the hold works both ways (2026-08-03) + +A scavenger has to be able to use their hold as a hold. **This narrows a +load-bearing rule**: "cargo leaves the hold only by sale or destruction" now +applies to **trader-bought cargo only**. + +- `travel/CargoMark` marks bought cargo in the item's own persistent data + (`tradewinds:traded`). Because the mark lives in the meta it survives the + hold's serialisation, travels through a boat capture, stays visible if a bug + ever leaks one into an inventory - and makes marked and unmarked stacks fail + `isSimilar`, so one bought diamond can never lock up twenty mined ones. +- `HoldService.withdraw` returns **-1** for trader-bought cargo rather than 0, so + the GUI can say *why* instead of just refusing. Overflow drops at the player's + feet rather than vanishing. +- Hold GUI gestures: **left-click takes cargo out**, shift-click sends burnables + to the fuel row (the old plain-click behaviour), right-click selects for the + TNT. The border pane's lore states all three. +- `MarketService.buy` marks what it sells you. Selling and destroying are + untouched - `remove()` is not gated, only `withdraw()`. + +**Bug found while doing it:** the existing `deposit` path added +`hold.add(player, material, amount)`, so loading an enchanted sword into the hold +stored a *plain* one. Harmless before Phase 4 (nothing had NBT to lose) and +silently destructive after it. + +**Pitfall:** `CargoMark` first built its key with `new NamespacedKey(plugin, ...)` +cached in a static - which needs a mocked plugin and outlives a reload. +`BoatService` already had the right pattern: `NamespacedKey.fromString`, no +Plugin instance at all. + ## Stage 7.5 Phase 4 — the hold carries NBT (2026-08-03) The hold stores **one ItemStack per slot** instead of material→amount, so a worn diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index dab20aa..0bdddf3 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -405,8 +405,11 @@ public int buy(Player player, IslandSpec spec, Material material, int amount) { thud(player); return 0; } - // Limit by hold slots: add first, pay for what fit - int added = addon.getHoldService().add(player, material, affordable); + // Limit by hold slots: add first, pay for what fit. Bought cargo is + // MARKED, so it cannot be withdrawn ashore later - speculate on a cargo + // and you must find a buyer for it + int added = addon.getHoldService().add(player, + world.bentobox.tradewinds.travel.CargoMark.marked(new ItemStack(material)), affordable); if (added <= 0) { user.sendMessage("tradewinds.trade.no-hold-space"); thud(player); diff --git a/src/main/java/world/bentobox/tradewinds/travel/CargoMark.java b/src/main/java/world/bentobox/tradewinds/travel/CargoMark.java new file mode 100644 index 0000000..03a7036 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/CargoMark.java @@ -0,0 +1,71 @@ +package world.bentobox.tradewinds.travel; + +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +/** + * Marks cargo bought from a trader, which is the one kind of cargo that may not + * come back out of the hold. + *

+ * The hold is two-way for goods a player loaded themselves - a scavenger needs + * to use their hold as a hold - but trader-bought cargo stays one-way + * (sale or destruction). That is what preserves cargo commitment: speculate on a + * cargo and you must find a buyer for it, rather than warehousing it ashore and + * waiting. It also keeps the hold from becoming an off-market channel for moving + * goods between players. + *

+ * The mark lives in the item's own persistent data, so it survives the hold's + * serialisation, travels with the goods through a boat capture, and stays visible + * if a bug ever leaks one into a player's inventory. It also makes marked and + * unmarked stacks fail {@link ItemStack#isSimilar}, which is exactly right: they + * must never merge, or one bought diamond would lock up twenty mined ones. + * + * @author tastybento + */ +public final class CargoMark { + + /** + * The mark. Built with {@code fromString} to match {@code BoatService}'s + * key: it needs no Plugin instance, so it neither holds a reference that + * outlives a reload nor needs one mocked in tests. + */ + public static final NamespacedKey TRADED_KEY = NamespacedKey.fromString("tradewinds:traded"); + + private CargoMark() { + // Static use only + } + + /** + * A copy of an item marked as bought from a trader. + * + * @param item the item + * @return the marked copy, or the original if it has no usable meta + */ + public static ItemStack marked(ItemStack item) { + ItemStack copy = item.clone(); + ItemMeta meta = copy.getItemMeta(); + if (meta == null) { + return item; + } + meta.getPersistentDataContainer().set(TRADED_KEY, PersistentDataType.BYTE, (byte) 1); + copy.setItemMeta(meta); + return copy; + } + + /** + * Whether this item was bought from a trader, and so may only leave the hold + * by being sold or destroyed. + * + * @param item the item + * @return true if it carries the mark + */ + public static boolean isTraded(ItemStack item) { + if (item == null) { + return false; + } + ItemMeta meta = item.getItemMeta(); + return meta != null && meta.getPersistentDataContainer().has(TRADED_KEY, PersistentDataType.BYTE); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java index b7c9924..6d8a3fa 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java @@ -124,7 +124,8 @@ private void render(Player player, Inventory inv) { HoldService hold = addon.getHoldService(); User user = User.getInstance(player); UUID id = player.getUniqueId(); - ItemStack border = pane(Material.LIGHT_BLUE_STAINED_GLASS_PANE, user, "tradewinds.hold.border", null); + ItemStack border = pane(Material.LIGHT_BLUE_STAINED_GLASS_PANE, user, "tradewinds.hold.border", + "tradewinds.hold.border-lore"); for (int slot = 0; slot < SIZE; slot++) { inv.setItem(slot, border); } @@ -362,7 +363,7 @@ private void deposit(Player player, ItemStack stack) { thud(player); return; } - int added = hold.add(player, material, stack.getAmount()); + int added = hold.add(player, stack, stack.getAmount()); if (added <= 0) { user.sendMessage("tradewinds.hold.cargo-full"); thud(player); @@ -454,15 +455,39 @@ private void topClick(Player player, int slot, ItemStack shown, ClickType click) Bukkit.getScheduler().runTask(addon.getPlugin(), () -> openNested(player, expander)); return; } - if (addon.getFuelService().fuelValue(shown.getType()) > 0) { - // Fuel-valued cargo (e.g. coal bought at market) moves to the - // fuel row on click - fuel is exempt from the one-way rule + // Right-click selects for the TNT; shift-click sends burnable cargo to + // the fuel row; a plain left-click takes the goods back out + if (click == ClickType.RIGHT) { + selected.put(id, new Selection(shown, shown.getAmount(), -1)); + user.sendMessage("tradewinds.hold.selected", "[amount]", String.valueOf(shown.getAmount()), + "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + return; + } + if (click.isShiftClick() && addon.getFuelService().fuelValue(shown.getType()) > 0) { moveCargoFuel(player, shown); return; } - selected.put(id, new Selection(shown, shown.getAmount(), -1)); - user.sendMessage("tradewinds.hold.selected", "[amount]", String.valueOf(shown.getAmount()), - "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + withdrawCargo(player, shown); + } + + /** + * Take cargo out of the hold. Trader-bought cargo is refused: the one-way + * rule still holds for anything a market sold you. + */ + private void withdrawCargo(Player player, ItemStack shown) { + User user = User.getInstance(player); + int taken = addon.getHoldService().withdraw(player, shown, shown.getAmount()); + if (taken < 0) { + user.sendMessage("tradewinds.hold.bought-cargo-locked"); + thud(player); + } else if (taken == 0) { + user.sendMessage("tradewinds.hold.withdraw-failed"); + thud(player); + } else { + user.sendMessage("tradewinds.hold.withdrawn", "[amount]", String.valueOf(taken), "[material]", + world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); + chime(player); + } } private void moveCargoFuel(Player player, ItemStack shown) { diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index 071a38a..b877652 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -276,6 +276,37 @@ public int removeFrom(BoatHold hold, Material material, int amount) { return removeFrom(hold, new ItemStack(material), amount); } + /** + * Take cargo back OUT of the hold and into the player's hands - the second + * exit, added 2026-08-03. A scavenger has to be able to use their hold as a + * hold; what stays one-way is cargo bought from a trader, which may + * still only leave by sale or destruction (see {@link CargoMark}). + *

+ * Anything that will not fit in the inventory is dropped at the player's + * feet rather than vanishing. + * + * @param player the player + * @param like the item to withdraw + * @param amount how many + * @return how many were withdrawn, or -1 if this cargo is trader-bought + */ + public int withdraw(Player player, ItemStack like, int amount) { + if (like == null || amount <= 0) { + return 0; + } + if (CargoMark.isTraded(like)) { + return -1; + } + int taken = remove(player, like, amount); + if (taken <= 0) { + return 0; + } + ItemStack out = CargoStore.copyOf(like, taken); + player.getInventory().addItem(out).values() + .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); + return taken; + } + /** * Move fuel-valued CARGO into the fuel row (e.g. coal bought at market) - * a sanctioned third exit from the cargo slots, into the tank. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 3527ec5..c39bd9b 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -256,11 +256,15 @@ tradewinds: title: "Ship's Hold - [slots] slots" border: " " tnt: "Destroy cargo" - tnt-lore: "Select a cargo stack, then click here. Destroyed means GONE." + tnt-lore: "Right-click a cargo stack to select it, then click here. Destroyed means GONE." locked: "Locked" locked-lore: "A bigger boat unlocks this slot." fuel-slot: "Fuel slot" fuel-slot-lore: "Wood, coal, lava. Fuel moves freely in and out." + border-lore: "Click to take cargo out. Shift-click burnables into the fuel row. Right-click to select for the TNT." + withdrawn: "Taken from the hold: [amount] x [material]" + withdraw-failed: "No room in your inventory for that." + bought-cargo-locked: "That cargo was bought for trade - it leaves the hold by sale or by the TNT, not back ashore." selected: "[actionbar]Selected [amount] x [material] - click the TNT to destroy it." select-first: "[actionbar]Select a cargo stack first, then click the TNT." destroyed: "[actionbar][amount] x [material] destroyed and gone." diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java index e6c2084..9d41c05 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java @@ -23,9 +23,9 @@ import world.bentobox.tradewinds.TestHolds; /** - * Tests the VIRTUAL hold: capacity comes from the one boat, contents are - * consolidated material→amount in the database, containers and vessels are - * refused, and fuel lives in its own seven slots. + * Tests the VIRTUAL hold: capacity comes from the one boat, cargo is one stack + * per slot in the database, containers and vessels are refused, and fuel lives + * in its own seven slots. * * @author tastybento */ @@ -49,6 +49,84 @@ public void setUp() throws Exception { when(addon.getHoldService()).thenReturn(service); } + @Test + void testTheHoldIsTwoWayForYourOwnGoods() { + // A scavenger has to be able to use their hold as a hold (2026-08-03) + holds.giveBoat(uuid, Material.OAK_BOAT); + ItemStack mined = new ItemStack(Material.COBBLESTONE); + assertEquals(40, service.add(mockPlayer, mined, 40)); + assertEquals(25, service.withdraw(mockPlayer, mined, 25)); + assertEquals(15, service.count(mockPlayer, mined), "Only what was taken leaves"); + assertEquals(15, service.withdraw(mockPlayer, mined, 999), "Asking for too much takes the rest"); + assertEquals(0, service.count(mockPlayer, mined)); + } + + @Test + void testTraderBoughtCargoStaysOneWay() { + // The load-bearing half of the rule: speculate on a cargo and you must + // find a buyer, not warehouse it ashore + holds.giveBoat(uuid, Material.OAK_BOAT); + ItemStack bought = markedStack(Material.DIAMOND); + assertEquals(10, service.add(mockPlayer, bought, 10)); + assertEquals(-1, service.withdraw(mockPlayer, bought, 10), "Bought cargo cannot be withdrawn"); + assertEquals(10, service.count(mockPlayer, bought), "and it is still aboard"); + // Selling and destroying still work - remove() is not gated + assertEquals(10, service.remove(mockPlayer, bought, 10)); + } + + @Test + void testBoughtAndMinedGoodsNeverMerge() { + // One bought diamond must not lock up twenty mined ones, so marked and + // unmarked stacks are different goods and keep separate slots + holds.giveBoat(uuid, Material.PALE_OAK_CHEST_BOAT); + ItemStack bought = markedStack(Material.DIAMOND); + ItemStack mined = new ItemStack(Material.DIAMOND); + assertEquals(5, service.add(mockPlayer, bought, 5)); + assertEquals(20, service.add(mockPlayer, mined, 20)); + assertFalse(CargoStore.stacksTogether(bought, mined), "Provenance must break similarity"); + assertEquals(5, service.count(mockPlayer, bought)); + assertEquals(20, service.count(mockPlayer, mined)); + assertEquals(2, service.cargo(uuid).size(), "Two goods, two slots"); + // And the mined ones come out while the bought ones stay put + assertEquals(20, service.withdraw(mockPlayer, mined, 20)); + assertEquals(5, service.count(mockPlayer, bought)); + } + + /** Every marked mock made in this test, so they can recognise each other. */ + private final java.util.Set markedMocks = java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap<>()); + + /** + * A stack carrying the trader-bought mark, with a mocked PDC and a working + * amount. Marked stacks are similar to each other and to nothing else, which + * is what the PDC mark does on a real server. + */ + private ItemStack markedStack(Material material) { + ItemStack item = mock(ItemStack.class); + org.bukkit.inventory.meta.ItemMeta meta = mock(org.bukkit.inventory.meta.ItemMeta.class); + org.bukkit.persistence.PersistentDataContainer pdc = + mock(org.bukkit.persistence.PersistentDataContainer.class); + java.util.concurrent.atomic.AtomicInteger amount = new java.util.concurrent.atomic.AtomicInteger(1); + when(item.getType()).thenReturn(material); + when(item.getMaxStackSize()).thenReturn(material.getMaxStackSize()); + when(item.getItemMeta()).thenReturn(meta); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(pdc.has(any(), any())).thenReturn(true); + when(item.getAmount()).thenAnswer(inv -> amount.get()); + org.mockito.Mockito.doAnswer(inv -> { + amount.set(inv.getArgument(0)); + return null; + }).when(item).setAmount(org.mockito.ArgumentMatchers.anyInt()); + when(item.isSimilar(any())).thenAnswer(inv -> { + Object other = inv.getArgument(0); + return other instanceof ItemStack stack && markedMocks.contains(stack) + && stack.getType() == material; + }); + when(item.clone()).thenAnswer(inv -> markedStack(material)); + markedMocks.add(item); + return item; + } + @Test void testNoBoatNoHold() { holds.clearBoat(uuid); From 180be8db05764e174cd86371bcd67a2f5f1f8342 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:49:19 -0700 Subject: [PATCH 083/112] Make prices discoverable without making them published (Phase 6) Two questions, and only one is hard. At the counter you know everything, so the sell tooltip now states the depth - "this port will take about N more at that price" - which is the number a seller actually needs, because it says when to stop selling and sail on. It falls straight out of Phase 1's absorbableValue. Across the map you should not know. So the logbook is a logbook and not an oracle: prices are recorded when you open a port's market, and /tw prices lists only ports you have actually called at, each stamped with how long ago you saw it. A port you have merely charted never appears. Stale readings showing their age is the feature - it is what makes a well-travelled trader more capable than a new one rather than just richer. Stored per category, because that is the granularity prices vary at. Harbour reports let a broker fill in your logbook for ports within the selling island's tech-scaled reach, which finally gives a developed port a role beyond what is on its shelves, and a reason to call at a hub you are not trading with. Two deliberate deviations from the plan, both recorded in PROGRESS. The logbook surfaces through /tw prices rather than the chart holograms: identical gameplay value, far less risk, and the renderer can adopt the same data later. And plaza rumours were not built at all - they were the cheap version of what the logbook and reports now do properly, so they would only add a third route to the same information. 289 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 10 ++ docs/PROGRESS.md | 34 ++++ .../world/bentobox/tradewinds/Settings.java | 14 ++ .../world/bentobox/tradewinds/TradeWinds.java | 2 + .../tradewinds/commands/TWPricesCommand.java | 161 ++++++++++++++++++ .../tradewinds/dataobjects/TWPlayerData.java | 61 +++++++ .../tradewinds/economy/MarketService.java | 76 +++++++++ .../tradewinds/economy/TradeDialog.java | 45 ++++- .../tradewinds/economy/TypeEconomy.java | 13 ++ src/main/resources/config.yml | 5 + src/main/resources/locales/en-US.yml | 19 ++- .../dataobjects/TWPlayerDataTest.java | 34 ++++ 12 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java diff --git a/TESTING.md b/TESTING.md index f70dfb8..b6eb36f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,6 +70,16 @@ noise. - [ ] Warp: you and the boat arrive facing the **dock**; hold forward and you reach it. - [ ] Sell the cargo at the new island at a different price than you paid. +- [ ] **The counter says when to stop.** On the sell page, a good's tooltip states + how many more the port will take at that price. Sell that many: the price + drops and the depth reads near zero. +- [ ] **The logbook only knows where you have been.** `/tw prices` on a fresh + player says the logbook is empty. Open a port's market, run it again: that + port appears with an age. A port you have only *charted* never appears. +- [ ] `/tw prices metals` sorts ports by what they paid for metals, best first. +- [ ] **Harbour reports fill the logbook.** Buy one at a high-tech port: several + nearby ports appear in `/tw prices` at once, money leaves, and a low-tech + port offers a smaller report (or none). - [ ] **The hold works both ways.** Mine cobblestone on an islet, load it into the hold, then left-click it in the hold window: it comes back to your inventory. With a full inventory the overflow drops at your feet, never diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index fe39cdb..212ece1 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,40 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 7.5 Phase 6 — price discovery (2026-08-03) + +Two different questions, and only one of them is hard. + +**At the counter you know everything.** The sell tooltip now states the *depth*: +"this port will take about N more at that price", from Phase 1's +`absorbableValue` divided by the unit price. That is the number a seller actually +needs, because it says when to stop selling and sail on. + +**Across the map you should not know.** A **logbook**, not an oracle: +`TWPlayerData.priceLog` (island -> category -> price seen) plus `priceLogSeen` +(when), written whenever a player opens a port's market. `/tw prices` lists ports +you have *called at* with their age; `/tw prices metals` sorts by best price for +one kind of goods. Prices for ports never visited are simply not knowable, and +stale readings show their age - which is what makes a well-travelled trader more +capable than a new one rather than merely richer. + +Category-level rather than per-material, because that is the granularity prices +vary at: type, tech, band and drift all move a whole category together. + +**Harbour reports** (`economy.market-report-price-per-island` 250, +`market-report-radius-per-tech-level` 1500) let a broker fill in your logbook for +ports within the selling island's tech-scaled reach. This gives a developed port a +role beyond its shelves and a reason to call at a hub you are not trading with. +No boat needed - it is information, not cargo. + +**Deviations from the plan, both deliberate:** +- The logbook surfaces through `/tw prices` rather than on the chart holograms. + The gameplay value ("where do I take this?") is identical, the risk is far + lower, and the hologram renderer can adopt the same data later. +- **Plaza rumours were not built.** They were the cheap flavour version of + exactly what the logbook and harbour reports now do properly, so they would + add a third route to the same information. Worth revisiting only as flavour. + ## Stage 7.5 Phase 5 — the hold works both ways (2026-08-03) A scavenger has to be able to use their hold as a hold. **This narrows a diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 9674523..4d87ec9 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -646,6 +646,16 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "player.max-restarts") private int maxRestarts = 3; + @ConfigComment("Harbour reports: what a broker charges PER PORT to fill in your logbook, and") + @ConfigComment("how far the report reaches per tech level of the island selling it. This gives") + @ConfigComment("a developed port a role beyond its shelves, and a reason to call somewhere you") + @ConfigComment("are not trading with. 0 for either disables reports.") + @ConfigEntry(path = "economy.market-report-price-per-island") + private double marketReportPricePerIsland = 250.0; + + @ConfigEntry(path = "economy.market-report-radius-per-tech-level") + private double marketReportRadiusPerTechLevel = 1500.0; + @ConfigComment("Enchantment premium: an item's price is multiplied by (1 + quality x this),") @ConfigComment("where quality weights each enchantment by usefulness and level. 0 ignores") @ConfigComment("enchantments, so a Silk Touch pick sells as a plain one.") @@ -3101,6 +3111,10 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setContrabandMaterials(List contrabandMaterials) { this.contrabandMaterials = contrabandMaterials; } public int getMaxRestarts() { return maxRestarts; } public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } + public double getMarketReportPricePerIsland() { return marketReportPricePerIsland; } + public void setMarketReportPricePerIsland(double v) { this.marketReportPricePerIsland = v; } + public double getMarketReportRadiusPerTechLevel() { return marketReportRadiusPerTechLevel; } + public void setMarketReportRadiusPerTechLevel(double v) { this.marketReportRadiusPerTechLevel = v; } public double getEnchantmentPriceFactor() { return enchantmentPriceFactor; } public void setEnchantmentPriceFactor(double v) { this.enchantmentPriceFactor = v; } public double getPotionEffectPrice() { return potionEffectPrice; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index f7de22b..0fffe85 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -32,6 +32,7 @@ import world.bentobox.tradewinds.commands.AdminTpIslandCommand; import world.bentobox.tradewinds.commands.AdminWarpFailCommand; import world.bentobox.tradewinds.commands.TWChartCommand; +import world.bentobox.tradewinds.commands.TWPricesCommand; import world.bentobox.tradewinds.commands.TWFineCommand; import world.bentobox.tradewinds.commands.TWRestartCommand; import world.bentobox.tradewinds.commands.TWSpawnCommand; @@ -189,6 +190,7 @@ public void setup() { // hop through another game mode is a free ride to a market. new TWWarpCommand(this); new TWChartCommand(this); + new TWPricesCommand(this); new TWStarChartCommand(this); new TWTradeCommand(this); new TWRestartCommand(this); diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java new file mode 100644 index 0000000..7907eed --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWPricesCommand.java @@ -0,0 +1,161 @@ +package world.bentobox.tradewinds.commands; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.economy.Money; +import world.bentobox.tradewinds.economy.TradeCategory; +import world.bentobox.tradewinds.galaxy.IslandSpec; + +/** + * The trader's logbook: what ports you have visited were paying, and how long + * ago you saw it. + *

+ * Deliberately NOT an oracle. Prices for ports you have never called at are not + * knowable, and remembered prices are stamped with their age because a market + * drifts after you leave. Stale data being visibly stale is the feature: it is + * what makes a well-travelled trader genuinely more capable than a new one, + * rather than merely richer. + *

+ * Optionally filtered to one category - {@code /tw prices metals} - because the + * question is usually "where do I take THIS?" + * + * @author tastybento + */ +public class TWPricesCommand extends CompositeCommand { + + private static final int MAX_ROWS = 12; + + public TWPricesCommand(CompositeCommand parent) { + super(parent, "prices"); + } + + @Override + public void setup() { + setPermission("island.prices"); + setOnlyPlayer(true); + setParametersHelp("tradewinds.commands.prices.parameters"); + setDescription("tradewinds.commands.prices.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + TradeCategory filter = null; + if (!args.isEmpty()) { + filter = category(args.get(0)); + if (filter == null) { + user.sendMessage("tradewinds.commands.prices.unknown-category", "[value]", args.get(0)); + return false; + } + } + var data = addon.getPlayerDataManager().get(user.getUniqueId()); + long now = System.currentTimeMillis(); + + // Only ports actually CALLED AT have prices - the chart knowing an island + // exists is not the same as having stood at its counter + List known = new ArrayList<>(); + if (addon.getOverWorld() != null) { + var engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); + for (String key : data.getPriceLog().keySet()) { + String[] cell = key.split(","); + if (cell.length != 2) { + continue; + } + try { + engine.islandInCell(Integer.parseInt(cell[0]), Integer.parseInt(cell[1])) + .ifPresent(known::add); + } catch (NumberFormatException e) { + // A key we did not write - ignore it rather than fail the command + } + } + } + known.removeIf(spec -> data.lastSeenPrices(spec) <= 0 || data.loggedPrices(spec).isEmpty()); + if (known.isEmpty()) { + user.sendMessage("tradewinds.commands.prices.nothing-logged"); + return true; + } + + final TradeCategory sought = filter; + if (sought != null) { + // Best price first: this is the whole point of keeping a logbook + known.sort(Comparator.comparingInt( + (IslandSpec spec) -> data.loggedPrices(spec).getOrDefault(sought.name(), 0)).reversed()); + user.sendMessage("tradewinds.commands.prices.header-category", "[value]", + prettyCategory(sought)); + } else { + known.sort(Comparator.comparingLong(data::lastSeenPrices).reversed()); + user.sendMessage("tradewinds.commands.prices.header"); + } + + for (IslandSpec spec : known.subList(0, Math.min(MAX_ROWS, known.size()))) { + Map prices = data.loggedPrices(spec); + String age = age(user, now - data.lastSeenPrices(spec)); + if (sought != null) { + Integer price = prices.get(sought.name()); + if (price == null || price <= 0) { + continue; + } + user.sendMessage("tradewinds.commands.prices.row-category", "[name]", spec.name(), "[price]", + Money.format(addon, price), "[age]", age); + } else { + user.sendMessage("tradewinds.commands.prices.row", "[name]", spec.name(), "[value]", + best(addon, prices), "[age]", age); + } + } + if (known.size() > MAX_ROWS) { + user.sendMessage("tradewinds.commands.prices.truncated", TextVariables.NUMBER, + String.valueOf(MAX_ROWS)); + } + return true; + } + + /** + * The best-paying category remembered at a port - the one line worth showing + * when not filtering. + */ + private String best(TradeWinds addon, Map prices) { + return prices.entrySet().stream().max(Map.Entry.comparingByValue()) + .map(entry -> prettyCategory(category(entry.getKey())) + " " + + Money.format(addon, entry.getValue())) + .orElse("-"); + } + + private static TradeCategory category(String name) { + for (TradeCategory category : TradeCategory.values()) { + if (category.name().equalsIgnoreCase(name)) { + return category; + } + } + return null; + } + + private static String prettyCategory(TradeCategory category) { + return category == null ? "-" + : world.bentobox.tradewinds.economy.PriceEngine.prettify(category.name()); + } + + /** + * How stale the reading is, in whole units, via the locale. + */ + private String age(User user, long millis) { + long minutes = Math.max(0, millis / 60_000L); + if (minutes < 60) { + return user.getTranslation("tradewinds.commands.prices.age-minutes", TextVariables.NUMBER, + String.valueOf(minutes)); + } + long hours = minutes / 60; + if (hours < 24) { + return user.getTranslation("tradewinds.commands.prices.age-hours", TextVariables.NUMBER, + String.valueOf(hours)); + } + return user.getTranslation("tradewinds.commands.prices.age-days", TextVariables.NUMBER, + String.valueOf(hours / 24)); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index e90d8df..b6d5184 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -1,6 +1,8 @@ package world.bentobox.tradewinds.dataobjects; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; import com.google.gson.annotations.Expose; @@ -96,6 +98,65 @@ public TWPlayerData(String uniqueId) { this.uniqueId = uniqueId; } + /** + * The price logbook: island key -> trade category -> the unit price SEEN + * there, and when. Prices for ports you have never visited are not knowable + * (Stage 7.5 Phase 6) - the market is learned, not published, which is what + * makes a veteran trader skilled rather than merely rich. + *

+ * Category-level rather than per-material because that is the granularity + * prices actually vary at: type, tech, band and drift all move a whole + * category together. + */ + @Expose + private Map> priceLog = new HashMap<>(); + + /** Island key -> when its prices were last observed, epoch millis. */ + @Expose + private Map priceLogSeen = new HashMap<>(); + + /** + * Record what a port was paying, now. + * + * @param spec the island + * @param prices category name -> unit price seen + * @param now epoch millis + */ + public void logPrices(IslandSpec spec, Map prices, long now) { + priceLog.put(chartKey(spec), new HashMap<>(prices)); + priceLogSeen.put(chartKey(spec), now); + } + + /** + * @return remembered prices at an island, category name -> unit price + */ + public Map loggedPrices(IslandSpec spec) { + return priceLog.getOrDefault(chartKey(spec), Map.of()); + } + + /** + * @return when this island's prices were seen, or 0 if never + */ + public long lastSeenPrices(IslandSpec spec) { + return priceLogSeen.getOrDefault(chartKey(spec), 0L); + } + + public Map> getPriceLog() { + return priceLog; + } + + public void setPriceLog(Map> priceLog) { + this.priceLog = priceLog == null ? new HashMap<>() : priceLog; + } + + public Map getPriceLogSeen() { + return priceLogSeen; + } + + public void setPriceLogSeen(Map priceLogSeen) { + this.priceLogSeen = priceLogSeen == null ? new HashMap<>() : priceLogSeen; + } + /** * The chart key for an island. */ diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 0bdddf3..fc82f6b 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -1,5 +1,6 @@ package world.bentobox.tradewinds.economy; +import java.util.List; import java.util.Optional; import org.bukkit.Material; @@ -162,6 +163,81 @@ public Optional playerSellsAt(IslandSpec spec, Material material) { return playerSellsAt(spec, new ItemStack(material)); } + /** + * A snapshot of what this port pays, by trade category - what goes into a + * trader's logbook. One representative good per category, because prices + * move by category: type, tech, band and drift all shift a whole category + * together. + * + * @param spec the island + * @return category name -> unit sell price + */ + public java.util.Map currentPrices(IslandSpec spec) { + java.util.Map prices = new java.util.HashMap<>(); + for (TradeCategory category : TradeCategory.values()) { + TypeEconomy.representative(category).ifPresent(sample -> playerSellsAt(spec, sample) + .ifPresent(unit -> prices.put(category.name(), unit.intValue()))); + } + return prices; + } + + /** + * Buy a harbour report: the broker fills in your logbook for the ports + * within reach of this one. Reach scales with the port's tech level, which + * gives a developed island a role beyond what is on its shelves - and a + * reason to call at a hub you are not trading with. + * + * @param player the player + * @param spec the island whose broker is selling + * @return how many ports were reported on, 0 if none or unaffordable + */ + public int buyMarketReport(Player player, IslandSpec spec) { + User user = User.getInstance(player); + Optional vault = addon.getPlugin().getVault(); + if (vault.isEmpty() || addon.getOverWorld() == null) { + return 0; + } + List ports = reportablePorts(spec); + if (ports.isEmpty()) { + user.sendMessage("tradewinds.trade.report-nothing"); + thud(player); + return 0; + } + double cost = Math.ceil(ports.size() * addon.getSettings().getMarketReportPricePerIsland()); + if (vault.get().getBalance(user) < cost) { + user.sendMessage("tradewinds.trade.cannot-afford"); + thud(player); + return 0; + } + vault.get().withdraw(user, cost); + var data = addon.getPlayerDataManager().get(player.getUniqueId()); + long now = System.currentTimeMillis(); + for (IslandSpec port : ports) { + data.chart(port); + data.logPrices(port, currentPrices(port), now); + } + addon.getPlayerDataManager().save(player.getUniqueId()); + user.sendMessage("tradewinds.trade.report-bought", world.bentobox.bentobox.api.localization + .TextVariables.NUMBER, String.valueOf(ports.size()), "[price]", Money.format(addon, cost)); + chime(player); + return ports.size(); + } + + /** + * The ports a broker here can report on: everything within this island's + * tech-scaled reach, itself excluded (you are standing in it). + */ + public List reportablePorts(IslandSpec spec) { + int radius = (int) Math.round(addon.getSettings().getMarketReportRadiusPerTechLevel() + * spec.techLevel()); + if (radius <= 0 || addon.getOverWorld() == null) { + return List.of(); + } + return addon.getGalaxyEngine(addon.getOverWorld().getSeed()) + .islandsNear(spec.centerX(), spec.centerZ(), radius).stream() + .filter(other -> other.cellX() != spec.cellX() || other.cellZ() != spec.cellZ()).toList(); + } + /** * Whether the player's boat is at this island - the cargo lives in the * boat, so trading needs the ship at the quay. "Here" means inside the diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 3cf040c..869351e 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -62,6 +62,9 @@ public void openMain(Player player, IslandSpec spec) { // open, or a sailor who has lost their boat can never get another // and is stranded on the island for good (playtest 2026-08-02). boolean boatHere = addon.getMarketService().boatIsHere(player, spec); + // Standing at the counter is how a trader learns a port's prices - and + // the only way, because the market is learned, not published + recordPrices(player, spec); // Too poor in fuel to warp anywhere from here? Then the most useful // thing this screen can do is point at where the fuel is sold. An // action bar fades; a button that says "buy fuel here" does not. @@ -86,6 +89,19 @@ public void openMain(Player player, IslandSpec spec) { () -> openOutfitter(player, spec))); buttons.add(button(player, "market.shipwright", "market.shipwright-tooltip", () -> openShipwright(player, spec))); + // The broker: pay to have your logbook filled in for the ports within + // this one's reach. No boat needed - it is information, not cargo. + int reportable = addon.getMarketService().reportablePorts(spec).size(); + if (reportable > 0 && addon.getSettings().getMarketReportPricePerIsland() > 0) { + buttons.add(button(ui(player, "market.report", NO_VARS), + ui(player, "market.report-tooltip", "[number]", String.valueOf(reportable), + "[price]", Money.format(addon, + reportable * addon.getSettings().getMarketReportPricePerIsland())), + () -> { + addon.getMarketService().buyMarketReport(player, spec); + openMain(player, spec); + })); + } List body = new ArrayList<>(List.of( ui(player, "market.subtitle", "[type]", spec.type().name(), "[tech]", String.valueOf(spec.techLevel()), "[band]", @@ -133,7 +149,7 @@ public void openSell(Player player, IslandSpec spec) { for (SellOffer offer : offers.subList(0, Math.min(MAX_ROWS, offers.size()))) { String name = MarketService.pretty(offer.material()); Component each = ui(player, "market.sell-tooltip-each", "[price]", - Money.format(addon, offer.unitPrice())); + Money.format(addon, offer.unitPrice()), "[depth]", depthOf(spec, offer)); buttons.add(button(ui(player, "market.sell-one", "[material]", name), each, () -> sellThenReopen(player, spec, offer.item(), 1))); buttons.add(button(ui(player, "market.sell-batch", "[material]", name, "[amount]", @@ -152,6 +168,22 @@ public void openSell(Player player, IslandSpec spec) { backButton(player, spec), 3); } + /** + * How many MORE of this good the port will take before its price hits the + * floor - the number a seller actually needs, because it says when to stop + * selling and sail on. Beyond the floor, dumping more is pure waste. + * + * @return the count, or "-" where the port is already saturated + */ + private String depthOf(IslandSpec spec, SellOffer offer) { + int headroom = addon.getIslandDataManager().absorbableValue(spec, + addon.getMarketService().driftPool(offer.material())); + if (offer.unitPrice() <= 0) { + return "-"; + } + return String.valueOf((int) Math.floor(headroom / offer.unitPrice())); + } + private void sellThenReopen(Player player, IslandSpec spec, ItemStack item, int amount) { addon.getMarketService().sell(player, spec, item, amount); openSell(player, spec); @@ -399,6 +431,17 @@ private Component ui(Player player, String key, String... variables) { return User.getInstance(player).getTranslationAsComponent("tradewinds.ui." + key, variables); } + /** + * Write this port's current prices into the player's logbook. Sell prices, + * because "where can I get a good price for this" is the question a trader + * actually asks. + */ + private void recordPrices(Player player, IslandSpec spec) { + var data = addon.getPlayerDataManager().get(player.getUniqueId()); + data.logPrices(spec, addon.getMarketService().currentPrices(spec), System.currentTimeMillis()); + addon.getPlayerDataManager().save(player.getUniqueId()); + } + private Component statusLine(Player player) { double balance = addon.getPlugin().getVault().map(v -> v.getBalance(User.getInstance(player))).orElse(0.0); return ui(player, "market.status", "[balance]", Money.format(addon, balance), "[space]", diff --git a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java index 15449e1..9801599 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java @@ -94,6 +94,19 @@ public static List catalog(IslandType type) { return produces(type).stream().flatMap(cat -> CATALOG.get(cat).stream()).toList(); } + /** + * A stand-in good for a category, for quoting a category-level price. The + * first entry of its catalog: prices move by category, so any member is + * representative of the rest. + * + * @param category the category + * @return a sample material, empty for categories with no catalog + */ + public static java.util.Optional representative(TradeCategory category) { + List goods = CATALOG.getOrDefault(category, List.of()); + return goods.isEmpty() ? java.util.Optional.empty() : java.util.Optional.of(goods.get(0)); + } + /** * Every material that is a recognised trade good somewhere in the * galaxy: the union of all type catalogs plus every outfitter shelf. diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 09f8209..a56152b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -290,6 +290,11 @@ economy: # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. unstamped-sellables: - SUGAR + # Harbour reports: what a broker charges PER PORT to fill in your logbook, and how + # far the report reaches per tech level of the island selling it. Gives a developed + # port a role beyond its shelves. 0 for either disables reports. + market-report-price-per-island: 250.0 + market-report-radius-per-tech-level: 1500.0 # Enchantment premium: price is multiplied by (1 + quality x this), where quality # weights each enchantment by usefulness and level. 0 ignores enchantments. enchantment-price-factor: 0.05 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index c39bd9b..acabe71 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -21,6 +21,19 @@ tradewinds: description: "open the warp dialog (in a boat, at an island)" chart: description: "raise the chart compass - islands, dock, and your boats ('list' for text)" + prices: + description: "your logbook: what ports you have called at were paying, and how long ago" + parameters: "[category]" + header: "Logbook - ports you have called at:" + header-category: "Logbook - best remembered prices for [value]:" + row: "[name] - best: [value] ([age])" + row-category: "[name] paid [price] ([age])" + nothing-logged: "Your logbook is empty. Prices are learned at the counter - call at a port and open its market." + unknown-category: "No such kind of goods: [value]" + truncated: "...and more. Showing the first [number]." + age-minutes: "[number]m ago" + age-hours: "[number]h ago" + age-days: "[number]d ago" starchart: description: "receive a Star Chart map of your charted islands" trade: @@ -156,6 +169,8 @@ tradewinds: buy-fuel-tooltip: "You cannot warp anywhere without more fuel. This island sells it." no-ship-here: "No ship at this quay, so there is no cargo to trade - but the outfitter and the shipwright will still serve you." low-fuel: "LOW FUEL - not enough to warp anywhere from here." + report: "Harbour report" + report-tooltip: "The broker will fill in your logbook for [number] nearby ports. $[price]" shipwright: "Shipwright" shipwright-tooltip: "Bigger boats - an upgrade keeps your cargo and grows the hold" back: "< Back" @@ -173,7 +188,7 @@ tradewinds: sell-one: "[material] x1" sell-batch: "[material] x[amount]" sell-all: "All [amount] - $[price]" - sell-tooltip-each: "$[price] each" + sell-tooltip-each: "$[price] each. This port will take about [depth] more at that price." buy-one: "[material] x1 - $[price]" buy-batch: "x[amount] - $[price]" buy-tooltip-each: "$[price] each; limited by balance and hold space" @@ -291,6 +306,8 @@ tradewinds: boat-crafted: "You fit out the [material] - [slots] cargo slots. Your old boat is broken up; your cargo is aboard." craft-smaller-refused: "That boat is no bigger than yours - you would be burning good wood to shrink your hold." too-advanced: "[name] has no use for [material] - nobody here could pay what it is worth. Try a more developed port." + report-bought: "The broker copies out [number] ports' prices into your logbook for [price]. Check them with /tw prices." + report-nothing: "No other ports within reach of this one - nothing to report on." contraband-refused: "The trader looks at what you are offering, then at you. 'Not here. Try a rougher port.'" barred: "The market closes as you approach. 'We know what you are. Take it somewhere lawless.'" nothing-to-sell: "Nothing in your hold that this island will pay for." diff --git a/src/test/java/world/bentobox/tradewinds/dataobjects/TWPlayerDataTest.java b/src/test/java/world/bentobox/tradewinds/dataobjects/TWPlayerDataTest.java index 42a9194..b041b81 100644 --- a/src/test/java/world/bentobox/tradewinds/dataobjects/TWPlayerDataTest.java +++ b/src/test/java/world/bentobox/tradewinds/dataobjects/TWPlayerDataTest.java @@ -9,6 +9,8 @@ import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.IslandType; +import world.bentobox.tradewinds.galaxy.SecurityBand; /** * Tests the player chart data object. @@ -31,4 +33,36 @@ void testCharting() { assertEquals(1, data.getChartedIslands().size()); assertEquals("1,2", TWPlayerData.chartKey(spec)); } + + @Test + void testThePriceLogbookRemembersWhatWasSeenAndWhen() { + TWPlayerData data = new TWPlayerData(java.util.UUID.randomUUID().toString()); + IslandSpec port = new IslandSpec(3, 4, 3000, 4000, IslandType.FISHING, SecurityBand.SAFE, + "minecraft:plains", "Cold Harbour", 3); + // Nothing is knowable about a port you have never called at + assertEquals(0L, data.lastSeenPrices(port)); + assertTrue(data.loggedPrices(port).isEmpty()); + + data.logPrices(port, java.util.Map.of("FISH", 31, "METALS", 12), 1_000_000L); + assertEquals(1_000_000L, data.lastSeenPrices(port)); + assertEquals(31, data.loggedPrices(port).get("FISH")); + + // A later call overwrites: the logbook holds the LAST reading, not a history + data.logPrices(port, java.util.Map.of("FISH", 18), 2_000_000L); + assertEquals(2_000_000L, data.lastSeenPrices(port)); + assertEquals(18, data.loggedPrices(port).get("FISH")); + assertFalse(data.loggedPrices(port).containsKey("METALS"), "Stale entries are replaced, not merged"); + } + + @Test + void testTheLogbookIsPerIsland() { + TWPlayerData data = new TWPlayerData(java.util.UUID.randomUUID().toString()); + IslandSpec one = new IslandSpec(1, 1, 1000, 1000, IslandType.MINING, SecurityBand.SAFE, + "minecraft:plains", "One", 3); + IslandSpec two = new IslandSpec(9, 9, 9000, 9000, IslandType.MINING, SecurityBand.SAFE, + "minecraft:plains", "Two", 3); + data.logPrices(one, java.util.Map.of("ORES", 50), 500L); + assertTrue(data.loggedPrices(two).isEmpty(), "Visiting one port teaches nothing about another"); + assertEquals(50, data.loggedPrices(one).get("ORES")); + } } From 7e0d6d3fad7ec9f64b1539ef13247fa729318e2c Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:52:54 -0700 Subject: [PATCH 084/112] Put notable goods back on the shelf (Stage 7.5 Phase 7) Sell an enchanted tool and it goes back out for sale somewhere else instead of vanishing, so the world reads as inhabited rather than as a sink. Only notable goods resurface - enchanted, renamed, or above resale-notable-value - because ordinary cargo is not interesting to find and would bury the things that are. The load-bearing part is that items surface at a DIFFERENT port from the one they were sold at. An unpredictable destination is what stops port shelves becoming an alt-account laundering channel, and the destination is seeded from the item and the selling port rather than random, so it cannot be re-rolled by retrying the sale. Two things guarded deliberately: a test asserts the shelf price exceeds what the same port would PAY for the item, since otherwise the loop is buy from shelf, sell at counter, repeat. And buyFromShelf adds to the hold before removing the listing, so a full hold cannot destroy a one-of-a-kind item. Shelf purchases arrive marked as trader-bought, so the one-way rule applies to them like anything else a market sells. 292 tests pass. Stage 7.5 is complete. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 9 ++ docs/PROGRESS.md | 25 ++++ .../world/bentobox/tradewinds/Settings.java | 43 +++++++ .../dataobjects/IslandDataManager.java | 54 ++++++++ .../tradewinds/dataobjects/TWIslandData.java | 61 +++++++++ .../tradewinds/economy/MarketService.java | 116 ++++++++++++++++++ .../tradewinds/economy/TradeDialog.java | 51 ++++++++ src/main/resources/config.yml | 16 +++ src/main/resources/locales/en-US.yml | 8 ++ .../economy/SalvagePricingTest.java | 38 ++++++ 10 files changed, 421 insertions(+) diff --git a/TESTING.md b/TESTING.md index b6eb36f..a0ddb57 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,6 +70,15 @@ noise. - [ ] Warp: you and the boat arrive facing the **dock**; hold forward and you reach it. - [ ] Sell the cargo at the new island at a different price than you paid. +- [ ] **Notable goods resurface elsewhere.** Sell an enchanted tool at a port, + then check the *neighbouring* ports' Secondhand shelves: it appears at one + of them, not at the port you sold it to. Ordinary cargo never appears. +- [ ] **The shelf is not a money printer.** A shelf item costs visibly more than + the same port would pay you for it. +- [ ] **A full hold does not eat a listing.** With no free slots, try to buy a + shelf item: refused, and the item is still on the shelf. +- [ ] Shelf items cannot be withdrawn from the hold afterwards (they are bought + cargo), and listings disappear after `resale-ttl-hours`. - [ ] **The counter says when to stop.** On the sell page, a good's tooltip states how many more the port will take at that price. Sell that many: the price drops and the depth reads near zero. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 212ece1..2a53ef2 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,31 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Stage 7.5 Phase 7 — the secondhand shelf (2026-08-03) + +Notable goods sold to a trader go back out for sale instead of vanishing, so the +world feels inhabited: *someone dumped a Silk Touch pick at Baker's Reach*. + +- `TWIslandData.shelf` is a bounded `List` (item + listed-at), swept by + `economy.resale-ttl-hours` (72) on access, `economy.resale-slots` (2) deep, with + the oldest listing making way for an arrival. +- **Only notable goods resurface**: enchanted, renamed, or worth at least + `economy.resale-notable-value` (500). Ordinary cargo is not interesting to find + and would bury the things that are. +- **They surface at a DIFFERENT port** than the one they were sold at, within + `economy.resale-ship-radius` (8000) - the trader shipped it on. This is the + load-bearing part: an unpredictable destination is what stops shelves becoming + an alt-account laundering channel. The destination is *seeded* from the item and + the selling port rather than random, so it cannot be re-rolled by retrying. +- `economy.resale-markup` (1.6) sits above book price, and a test asserts the + shelf price exceeds what the same port would PAY for the item - otherwise buy + from the shelf, sell at the counter, repeat. +- Shelf purchases arrive **marked as trader-bought**, so they are subject to the + one-way rule like anything else a market sells. + +**Ordering detail worth keeping:** `buyFromShelf` adds to the hold *before* +removing the listing, so a full hold cannot destroy a one-of-a-kind item. + ## Stage 7.5 Phase 6 — price discovery (2026-08-03) Two different questions, and only one of them is hard. diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 4d87ec9..70b5e61 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -646,6 +646,37 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "player.max-restarts") private int maxRestarts = 3; + @ConfigComment("The secondhand shelf: notable goods sold to a trader go back out for sale") + @ConfigComment("instead of vanishing, so the world feels inhabited - 'someone dumped a Silk") + @ConfigComment("Touch pick at Baker's Reach'. They surface at a DIFFERENT port from the one") + @ConfigComment("they were sold at (the trader shipped it on), which is what stops shelves") + @ConfigComment("becoming an alt-account laundering channel: you cannot predict where your own") + @ConfigComment("goods reappear. false disables resale entirely.") + @ConfigEntry(path = "economy.resale-enabled") + private boolean resaleEnabled = true; + + @ConfigComment("Only NOTABLE goods resurface: enchanted, renamed, or worth at least this much.") + @ConfigComment("Ordinary cargo is not interesting to find and would only clutter the shelves.") + @ConfigEntry(path = "economy.resale-notable-value") + private double resaleNotableValue = 500.0; + + @ConfigComment("Listings a port may carry at once; the oldest makes way for a new arrival.") + @ConfigEntry(path = "economy.resale-slots") + private int resaleSlots = 2; + + @ConfigComment("How long a listing lasts, in hours. 0 for never expiring.") + @ConfigEntry(path = "economy.resale-ttl-hours") + private int resaleTtlHours = 72; + + @ConfigComment("What the trader adds on top of book price to resell. Must be above 1 or the") + @ConfigComment("shelf becomes a way to buy back your own goods at a profit.") + @ConfigEntry(path = "economy.resale-markup") + private double resaleMarkup = 1.6; + + @ConfigComment("How far a trader will ship a consignment to put it on someone else's shelf.") + @ConfigEntry(path = "economy.resale-ship-radius") + private int resaleShipRadius = 8000; + @ConfigComment("Harbour reports: what a broker charges PER PORT to fill in your logbook, and") @ConfigComment("how far the report reaches per tech level of the island selling it. This gives") @ConfigComment("a developed port a role beyond its shelves, and a reason to call somewhere you") @@ -3111,6 +3142,18 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setContrabandMaterials(List contrabandMaterials) { this.contrabandMaterials = contrabandMaterials; } public int getMaxRestarts() { return maxRestarts; } public void setMaxRestarts(int maxRestarts) { this.maxRestarts = maxRestarts; } + public boolean isResaleEnabled() { return resaleEnabled; } + public void setResaleEnabled(boolean resaleEnabled) { this.resaleEnabled = resaleEnabled; } + public double getResaleNotableValue() { return resaleNotableValue; } + public void setResaleNotableValue(double v) { this.resaleNotableValue = v; } + public int getResaleSlots() { return resaleSlots; } + public void setResaleSlots(int resaleSlots) { this.resaleSlots = resaleSlots; } + public int getResaleTtlHours() { return resaleTtlHours; } + public void setResaleTtlHours(int resaleTtlHours) { this.resaleTtlHours = resaleTtlHours; } + public double getResaleMarkup() { return resaleMarkup; } + public void setResaleMarkup(double resaleMarkup) { this.resaleMarkup = resaleMarkup; } + public int getResaleShipRadius() { return resaleShipRadius; } + public void setResaleShipRadius(int resaleShipRadius) { this.resaleShipRadius = resaleShipRadius; } public double getMarketReportPricePerIsland() { return marketReportPricePerIsland; } public void setMarketReportPricePerIsland(double v) { this.marketReportPricePerIsland = v; } public double getMarketReportRadiusPerTechLevel() { return marketReportRadiusPerTechLevel; } diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java b/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java index eab805a..1f5c628 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/IslandDataManager.java @@ -129,6 +129,60 @@ static int decayed(int stock, int amount) { return Math.min(0, stock + amount); } + /** + * A port's secondhand shelf, swept of expired listings. + * + * @param spec the island + * @param ttlHours how long a listing lasts, 0 or less for no expiry + * @return the live listings + */ + public java.util.List shelf(IslandSpec spec, int ttlHours) { + TWIslandData data = get(spec); + if (ttlHours > 0) { + long cutoff = System.currentTimeMillis() - ttlHours * 3_600_000L; + if (data.getShelf().removeIf(listing -> listing.getItem() == null + || listing.getListedAt() < cutoff)) { + handler.saveObjectAsync(data); + } + } + return data.getShelf(); + } + + /** + * Put an item on a port's shelf, oldest listing making way when full. + * + * @param spec the island + * @param item the item + * @param slots how many listings this port may carry + */ + public void consign(IslandSpec spec, org.bukkit.inventory.ItemStack item, int slots) { + if (item == null || slots <= 0) { + return; + } + TWIslandData data = get(spec); + data.getShelf().add(new TWIslandData.ShelfItem(item, System.currentTimeMillis())); + // Bounded: the oldest listing goes when a new one arrives + while (data.getShelf().size() > slots) { + data.getShelf().remove(0); + } + handler.saveObjectAsync(data); + } + + /** + * Take a listing off a port's shelf. + * + * @return the item, or null if the index is stale + */ + public org.bukkit.inventory.ItemStack takeFromShelf(IslandSpec spec, int index) { + TWIslandData data = get(spec); + if (index < 0 || index >= data.getShelf().size()) { + return null; + } + org.bukkit.inventory.ItemStack item = data.getShelf().remove(index).getItem(); + handler.saveObjectAsync(data); + return item; + } + public void saveAll() { cache.values().forEach(handler::saveObjectAsync); } diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java index 40b8bec..875c8fc 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWIslandData.java @@ -1,8 +1,12 @@ package world.bentobox.tradewinds.dataobjects; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +import org.bukkit.inventory.ItemStack; + import com.google.gson.annotations.Expose; import world.bentobox.bentobox.database.objects.DataObject; @@ -40,6 +44,63 @@ public class TWIslandData implements DataObject { @Expose private long lastDecay; + /** + * The secondhand shelf: notable goods a trader took in and put back out for + * sale. Bounded and swept by TTL. + *

+ * Items surface at a DIFFERENT island from the one they were sold at - the + * trader shipped it on - which is what stops port shelves becoming an + * alt-account laundering channel: you cannot predict where your own goods + * will reappear. + */ + @Expose + private List shelf = new ArrayList<>(); + + /** + * One listing on a secondhand shelf. + */ + public static class ShelfItem { + + @Expose + private ItemStack item; + + @Expose + private long listedAt; + + public ShelfItem() { + // Required by the database + } + + public ShelfItem(ItemStack item, long listedAt) { + this.item = item; + this.listedAt = listedAt; + } + + public ItemStack getItem() { + return item; + } + + public void setItem(ItemStack item) { + this.item = item; + } + + public long getListedAt() { + return listedAt; + } + + public void setListedAt(long listedAt) { + this.listedAt = listedAt; + } + } + + public List getShelf() { + return shelf; + } + + public void setShelf(List shelf) { + this.shelf = shelf == null ? new ArrayList<>() : shelf; + } + public TWIslandData() { // Required by the database } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index fc82f6b..b68de88 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -14,6 +14,7 @@ import world.bentobox.tradewinds.api.events.TWTradeEvent; import world.bentobox.tradewinds.galaxy.IslandSpec; import world.bentobox.tradewinds.travel.BoatRanks; +import world.bentobox.tradewinds.travel.CargoStore; /** * The market: prices and trades. Base prices come from the embedded @@ -163,6 +164,119 @@ public Optional playerSellsAt(IslandSpec spec, Material material) { return playerSellsAt(spec, new ItemStack(material)); } + /** + * Whether an item is worth putting back on a shelf: enchanted, renamed, or + * simply valuable. Ordinary cargo is not interesting to find, and would only + * bury the things that are. + * + * @param item the item + * @return true if it should resurface + */ + public boolean isNotable(ItemStack item) { + if (item == null || !addon.getSettings().isResaleEnabled()) { + return false; + } + if (!item.getEnchantments().isEmpty()) { + return true; + } + var meta = item.getItemMeta(); + if (meta != null && meta.hasDisplayName()) { + return true; + } + return basePrice(item).orElse(0.0) >= addon.getSettings().getResaleNotableValue(); + } + + /** + * Ship a notable item on to ANOTHER port's shelf, so it can be found by + * someone else. Never the port it was sold at: an unpredictable destination + * is what keeps shelves from becoming an alt-account laundering channel. + * + * @param soldAt where it was sold + * @param item the item, one unit + */ + public void consign(IslandSpec soldAt, ItemStack item) { + if (!isNotable(item) || addon.getOverWorld() == null) { + return; + } + List elsewhere = addon.getGalaxyEngine(addon.getOverWorld().getSeed()) + .islandsNear(soldAt.centerX(), soldAt.centerZ(), addon.getSettings().getResaleShipRadius()) + .stream().filter(other -> other.cellX() != soldAt.cellX() || other.cellZ() != soldAt.cellZ()) + .toList(); + if (elsewhere.isEmpty()) { + return; + } + // Seeded by the item and the port, so it is arbitrary but not random - + // scripts cannot re-roll it, and it stays the same on a resumed sale + int pick = Math.abs((item.getType().name() + soldAt.cellX() + "," + soldAt.cellZ()).hashCode()) + % elsewhere.size(); + ItemStack one = CargoStore.copyOf(item, 1); + addon.getIslandDataManager().consign(elsewhere.get(pick), one, addon.getSettings().getResaleSlots()); + } + + /** + * A port's secondhand shelf. + */ + public List shelf(IslandSpec spec) { + if (!addon.getSettings().isResaleEnabled()) { + return List.of(); + } + return addon.getIslandDataManager().shelf(spec, addon.getSettings().getResaleTtlHours()).stream() + .map(world.bentobox.tradewinds.dataobjects.TWIslandData.ShelfItem::getItem) + .filter(java.util.Objects::nonNull).toList(); + } + + /** + * What a shelf item costs: book price plus the trader's markup. Above what + * the same port would pay for it, or the shelf would be a money printer. + */ + public Optional shelfPrice(ItemStack item) { + return basePrice(item) + .map(base -> Math.max(1, Math.ceil(base * addon.getSettings().getResaleMarkup()))); + } + + /** + * Buy one listing off a port's shelf, into the hold. It arrives MARKED as + * trader-bought, like anything else a market sells. + * + * @return true if bought + */ + public boolean buyFromShelf(Player player, IslandSpec spec, int index) { + User user = User.getInstance(player); + Optional vault = addon.getPlugin().getVault(); + List shelf = shelf(spec); + if (vault.isEmpty() || index < 0 || index >= shelf.size()) { + return false; + } + if (!boatIsHere(player, spec)) { + user.sendMessage("tradewinds.trade.boat-not-here"); + thud(player); + return false; + } + ItemStack item = shelf.get(index); + Optional price = shelfPrice(item); + if (price.isEmpty()) { + return false; + } + if (vault.get().getBalance(user) < price.get()) { + user.sendMessage("tradewinds.trade.cannot-afford"); + thud(player); + return false; + } + ItemStack marked = world.bentobox.tradewinds.travel.CargoMark.marked(item); + if (addon.getHoldService().add(player, marked, 1) <= 0) { + user.sendMessage("tradewinds.trade.no-hold-space"); + thud(player); + return false; + } + // Only now take it off the shelf, so a full hold cannot destroy a listing + addon.getIslandDataManager().takeFromShelf(spec, index); + vault.get().withdraw(user, price.get()); + user.sendMessage("tradewinds.trade.shelf-bought", "[material]", pretty(item.getType()), "[price]", + Money.format(addon, price.get())); + chime(player); + return true; + } + /** * A snapshot of what this port pays, by trade category - what goes into a * trader's logbook. One representative good per category, because prices @@ -443,6 +557,8 @@ public int sell(Player player, IslandSpec spec, ItemStack item, int amount) { // has just paid out for ten diamonds is far closer to saturated than one // that bought ten wheat addon.getIslandDataManager().adjustStockValue(spec, driftPool(material), (int) Math.round(total)); + // Notable goods go back out for sale somewhere else rather than vanishing + consign(spec, item); User.getInstance(player).sendMessage("tradewinds.trade.sold", "[amount]", String.valueOf(removed), "[material]", pretty(material), "[price]", Money.format(addon, total)); chime(player); diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 869351e..1ffcef4 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -89,6 +89,11 @@ public void openMain(Player player, IslandSpec spec) { () -> openOutfitter(player, spec))); buttons.add(button(player, "market.shipwright", "market.shipwright-tooltip", () -> openShipwright(player, spec))); + // The secondhand shelf, when this port has anything on it + if (boatHere && !addon.getMarketService().shelf(spec).isEmpty()) { + buttons.add(button(player, "market.shelf", "market.shelf-tooltip", + () -> openShelf(player, spec))); + } // The broker: pay to have your logbook filled in for the ports within // this one's reach. No boat needed - it is information, not cargo. int reportable = addon.getMarketService().reportablePorts(spec).size(); @@ -168,6 +173,52 @@ public void openSell(Player player, IslandSpec spec) { backButton(player, spec), 3); } + /** + * The secondhand shelf: notable goods other sailors sold on, shipped here by + * their traders. One button per listing. + */ + public void openShelf(Player player, IslandSpec spec) { + List shelf = addon.getMarketService().shelf(spec); + if (shelf.isEmpty()) { + User.getInstance(player).sendMessage("tradewinds.trade.shelf-empty"); + openMain(player, spec); + return; + } + List buttons = new ArrayList<>(); + for (int i = 0; i < Math.min(MAX_ROWS, shelf.size()); i++) { + ItemStack item = shelf.get(i); + final int index = i; + String price = addon.getMarketService().shelfPrice(item) + .map(value -> Money.format(addon, value)).orElse("-"); + buttons.add(button(ui(player, "market.shelf-buy", "[material]", MarketService.pretty(item.getType()), + "[price]", price), ui(player, "market.shelf-buy-tooltip", "[details]", details(item)), + () -> { + addon.getMarketService().buyFromShelf(player, spec, index); + openShelf(player, spec); + })); + } + show(player, ui(player, "market.shelf-title", "[name]", spec.name()), + new ArrayList<>(List.of(ui(player, "market.shelf-body", NO_VARS), statusLine(player))), + buttons, backButton(player, spec), 2); + } + + /** + * What makes a shelf item worth looking at - its name, or its enchantments. + */ + private String details(ItemStack item) { + var meta = item.getItemMeta(); + if (meta != null && meta.hasDisplayName()) { + return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(meta.displayName()); + } + if (!item.getEnchantments().isEmpty()) { + return item.getEnchantments().entrySet().stream() + .map(e -> PriceEngine.prettify(e.getKey().getKey().getKey()) + " " + e.getValue()) + .reduce((a, b) -> a + ", " + b).orElse(""); + } + return MarketService.pretty(item.getType()); + } + /** * How many MORE of this good the port will take before its price hits the * floor - the number a seller actually needs, because it says when to stop diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a56152b..40c045c 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -290,6 +290,22 @@ economy: # Materials traders buy even WITHOUT a customs stamp - the contraband exceptions. unstamped-sellables: - SUGAR + # The secondhand shelf: notable goods sold to a trader go back out for sale + # instead of vanishing, so the world feels inhabited. They surface at a DIFFERENT + # port from the one they were sold at (the trader shipped it on), which stops + # shelves becoming an alt-account laundering channel - you cannot predict where + # your own goods reappear. false disables resale entirely. + resale-enabled: true + # Only NOTABLE goods resurface: enchanted, renamed, or worth at least this much. + resale-notable-value: 500.0 + # Listings a port carries at once; oldest makes way. TTL in hours, 0 = forever. + resale-slots: 2 + resale-ttl-hours: 72 + # Trader's markup on book price. Must exceed 1 or the shelf lets players buy back + # their own goods at a profit. + resale-markup: 1.6 + # How far a trader ships a consignment to reach another port's shelf. + resale-ship-radius: 8000 # Harbour reports: what a broker charges PER PORT to fill in your logbook, and how # far the report reaches per tech level of the island selling it. Gives a developed # port a role beyond its shelves. 0 for either disables reports. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index acabe71..5cf2005 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -169,6 +169,12 @@ tradewinds: buy-fuel-tooltip: "You cannot warp anywhere without more fuel. This island sells it." no-ship-here: "No ship at this quay, so there is no cargo to trade - but the outfitter and the shipwright will still serve you." low-fuel: "LOW FUEL - not enough to warp anywhere from here." + shelf: "Secondhand" + shelf-tooltip: "Goods other sailors sold on, shipped here by their traders." + shelf-title: "Secondhand at [name]" + shelf-body: "Odds and ends the trader took in. Sold as seen." + shelf-buy: "[material] - $[price]" + shelf-buy-tooltip: "[details]" report: "Harbour report" report-tooltip: "The broker will fill in your logbook for [number] nearby ports. $[price]" shipwright: "Shipwright" @@ -306,6 +312,8 @@ tradewinds: boat-crafted: "You fit out the [material] - [slots] cargo slots. Your old boat is broken up; your cargo is aboard." craft-smaller-refused: "That boat is no bigger than yours - you would be burning good wood to shrink your hold." too-advanced: "[name] has no use for [material] - nobody here could pay what it is worth. Try a more developed port." + shelf-bought: "Yours: [material] for [price]" + shelf-empty: "Nothing on the secondhand shelf here." report-bought: "The broker copies out [number] ports' prices into your logbook for [price]. Check them with /tw prices." report-nothing: "No other ports within reach of this one - nothing to report on." contraband-refused: "The trader looks at what you are offering, then at you. 'Not here. Try a rougher port.'" diff --git a/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java b/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java index adc7962..17dd2bb 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/SalvagePricingTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -149,4 +150,41 @@ void testSalvageStillCannotBeArbitraged() { assertTrue(sell < buy, "Round trip profits at discount " + discount + ": " + sell + " >= " + buy); } } + + @Test + void testOnlyNotableGoodsResurface() { + // Ordinary cargo is not interesting to find and would bury what is + assertFalse(service.isNotable(new ItemStack(Material.WHEAT))); + assertFalse(service.isNotable(new ItemStack(Material.COBBLESTONE))); + assertFalse(service.isNotable(null)); + // Valuable things do, at the configured threshold of 500 + assertTrue(service.isNotable(new ItemStack(Material.DIAMOND))); + assertTrue(service.isNotable(new ItemStack(Material.NETHER_STAR))); + // And the switch turns the whole feature off + settings.setResaleEnabled(false); + assertFalse(service.isNotable(new ItemStack(Material.DIAMOND))); + } + + @Test + void testTheShelfNeverUndercutsTheCounter() { + // A shelf that sold below what the same port pays would be a money + // printer: buy from the shelf, sell at the counter, repeat + for (Material material : new Material[] { Material.DIAMOND, Material.ENDER_PEARL, + Material.IRON_INGOT, Material.NETHER_STAR }) { + ItemStack item = new ItemStack(material); + double shelf = service.shelfPrice(item).orElseThrow(); + double counterPays = service.playerSellsAt(port, item).orElse(0.0); + assertTrue(shelf > counterPays, + material + ": shelf " + shelf + " must exceed what the port pays " + counterPays); + } + } + + @Test + void testShelfPriceIsAWholeCoinAndNeverFree() { + for (Material material : new Material[] { Material.ROTTEN_FLESH, Material.DIAMOND, Material.DIRT }) { + double price = service.shelfPrice(new ItemStack(material)).orElseThrow(); + assertEquals(price, Math.floor(price), "Shelf price not whole: " + price); + assertTrue(price >= 1); + } + } } From 10c4620f8df06816959cd6cc44ee089a7614a92a Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 18:53:25 -0700 Subject: [PATCH 085/112] Mark the salvage plan delivered Records at the top of the plan that all seven phases landed, and points at PROGRESS for the deviations, so the plan does not read as outstanding work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- tradewinds-salvage-plan.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tradewinds-salvage-plan.md b/tradewinds-salvage-plan.md index 9d42bb8..de7680f 100644 --- a/tradewinds-salvage-plan.md +++ b/tradewinds-salvage-plan.md @@ -5,6 +5,11 @@ Where this document and `TRADEWINDS_SPEC.md` disagree on those subjects, this one wins. It sits alongside `tradewinds-hold-plan.md`, which remains normative for boat identity, ownership and capture. +> **Status: all seven phases delivered 2026-08-03.** See `docs/PROGRESS.md` for +> what each landed, the deviations, and the traps hit. Two things were +> deliberately not built: plaza rumours (Phase 6 - they duplicated the logbook) +> and the chart-hologram price display (the logbook surfaces via `/tw prices`). + ## The problem A player scavenging islets, farming, or taking prizes at sea accumulates goods From 28cf6114dd681797ade1bdada48fed48d0286805 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 19:21:36 -0700 Subject: [PATCH 086/112] Survive config prices written without a decimal point Right-clicking a trader at spawn threw ClassCastException: Integer cannot be cast to Double, inside PriceEngine.getPrice. My fault from Phase 2. When I regenerated the base-prices block I wrote "WHEAT: 20" rather than "20.0", and YAML types a number by how it is written. BentoBox's deserializer promotes Integer to Long but not to Double, so the Integer went straight into a Map - where generic erasure means nothing complains until the first read, at the trader, mid-dialog. Fixed at both levels. The shipped config now writes decimals, but that alone would be a trap waiting for the first admin to hand-edit a price as "20", so Settings also coerces every Map through asDoubles() on the way in - base prices, fuel values, crime bounties and edge overrides. The wildcard parameter type on that helper is load-bearing and commented as such: declaring it Map would make the compiler insert the very cast that throws. Three tests: the crash reproduced through the setter with Integer, Long and junk values; every numeric map covered; and a guard that the shipped config writes prices as decimals. No database wipe needed - the coercion reads the existing server config fine. 295 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- .../world/bentobox/tradewinds/Settings.java | 38 ++- src/main/resources/config.yml | 230 +++++++++--------- .../bentobox/tradewinds/SettingsTest.java | 58 +++++ 3 files changed, 207 insertions(+), 119 deletions(-) diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 70b5e61..0c90706 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -720,6 +720,36 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "economy.base-prices") private Map basePrices = defaultBasePrices(); + /** + * Coerce a config-loaded number map to actual Doubles. + *

+ * BentoBox's YAML deserializer promotes Integer to Long but not to + * Double, so a config value written as {@code 20} rather than {@code 20.0} + * arrives as an Integer inside a {@code Map} - generics are + * erased, so nothing complains until the first read throws + * ClassCastException. An admin editing prices by hand will write {@code 20} + * every time, so this cannot be left to the config file being tidy. + *

+ * The wildcard parameter type is load-bearing: declaring it + * {@code Map} would make the compiler insert the very cast + * that blows up. + * + * @param raw the map as the config loader left it + * @return a map whose values really are Doubles + */ + private static Map asDoubles(Map raw) { + Map clean = new HashMap<>(); + if (raw == null) { + return clean; + } + raw.forEach((key, value) -> { + if (value instanceof Number number) { + clean.put(key, number.doubleValue()); + } + }); + return clean; + } + private static Map defaultBasePrices() { Map map = new HashMap<>(); map.put("WHEAT", 20.0); map.put("CARROT", 15.0); map.put("POTATO", 15.0); map.put("BEETROOT", 15.0); @@ -3012,9 +3042,9 @@ public void setConcurrentIslands(int concurrentIslands) { public double getFuelWarningMargin() { return fuelWarningMargin; } public void setFuelWarningMargin(double v) { this.fuelWarningMargin = v; } public Map getFuelValues() { return fuelValues; } - public void setFuelValues(Map fuelValues) { this.fuelValues = fuelValues; } + public void setFuelValues(Map fuelValues) { this.fuelValues = asDoubles(fuelValues); } public Map getEdgeOverrides() { return edgeOverrides; } - public void setEdgeOverrides(Map edgeOverrides) { this.edgeOverrides = edgeOverrides; } + public void setEdgeOverrides(Map edgeOverrides) { this.edgeOverrides = asDoubles(edgeOverrides); } public Map getBandMonsterSpawn() { return bandMonsterSpawn; } public void setBandMonsterSpawn(Map bandMonsterSpawn) { this.bandMonsterSpawn = bandMonsterSpawn; } public Map getBandPvp() { return bandPvp; } @@ -3061,7 +3091,7 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public Map getCrimePenalties() { return crimePenalties; } public void setCrimePenalties(Map m) { this.crimePenalties = m; } public Map getCrimeBounties() { return crimeBounties; } - public void setCrimeBounties(Map m) { this.crimeBounties = m; } + public void setCrimeBounties(Map m) { this.crimeBounties = asDoubles(m); } public List getPortDeniedFlags() { return portDeniedFlags; } public void setPortDeniedFlags(List portDeniedFlags) { this.portDeniedFlags = portDeniedFlags; } public List getPortAllowedFlags() { return portAllowedFlags; } @@ -3167,7 +3197,7 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public double getSalvageDiscount() { return salvageDiscount; } public void setSalvageDiscount(double salvageDiscount) { this.salvageDiscount = salvageDiscount; } public Map getBasePrices() { return basePrices; } - public void setBasePrices(Map basePrices) { this.basePrices = basePrices; } + public void setBasePrices(Map basePrices) { this.basePrices = asDoubles(basePrices); } public double getIntersticeGhastChance() { return intersticeGhastChance; } public void setIntersticeGhastChance(double v) { this.intersticeGhastChance = v; } public double getIntersticeGhastDistance() { return intersticeGhastDistance; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 40c045c..05fd119 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -332,121 +332,121 @@ economy: # Base prices (Material -> price). Anything not listed is priced by deriving from # its crafting recipe (BlueBook logic, embedded); underivable = untradeable. base-prices: - WHEAT: 20 - CARROT: 15 - POTATO: 15 - BEETROOT: 15 - SUGAR_CANE: 10 - SUGAR: 15 - PUMPKIN: 30 - MELON_SLICE: 5 - BREAD: 30 - COOKED_BEEF: 40 - COOKED_COD: 30 - CAKE: 200 - GOLDEN_APPLE: 1500 - EGG: 10 - HAY_BLOCK: 180 - COD: 20 - SALMON: 30 - TROPICAL_FISH: 50 - PUFFERFISH: 40 - KELP: 3 - OAK_LOG: 15 - SPRUCE_LOG: 15 - BIRCH_LOG: 15 - DARK_OAK_LOG: 15 - ACACIA_LOG: 15 - JUNGLE_LOG: 15 - CHERRY_LOG: 20 - STONE: 5 - COBBLESTONE: 3 - GRANITE: 4 - DIORITE: 4 - ANDESITE: 4 - DEEPSLATE: 6 - SAND: 3 - GRAVEL: 3 - COAL: 40 - CHARCOAL: 30 - RAW_IRON: 60 - RAW_COPPER: 30 - RAW_GOLD: 120 - FLINT: 10 - IRON_INGOT: 90 - COPPER_INGOT: 40 - GOLD_INGOT: 180 - IRON_NUGGET: 10 - GOLD_NUGGET: 20 - DIAMOND: 1000 - EMERALD: 600 - AMETHYST_SHARD: 100 - QUARTZ: 80 - LAPIS_LAZULI: 60 - REDSTONE: 30 - LEATHER: 40 - WHITE_WOOL: 20 - STRING: 15 - BEEF: 25 - PORKCHOP: 25 - CHICKEN: 20 - MUTTON: 20 - BONE: 20 - BONE_MEAL: 8 - GUNPOWDER: 50 - SPIDER_EYE: 30 - ROTTEN_FLESH: 5 - FEATHER: 15 - INK_SAC: 25 - GLOW_INK_SAC: 90 - SLIME_BALL: 40 - RABBIT: 20 - RABBIT_HIDE: 15 - RABBIT_FOOT: 150 - ENDER_PEARL: 250 - BLAZE_ROD: 200 - GHAST_TEAR: 400 - PHANTOM_MEMBRANE: 180 - NETHER_STAR: 5000 - PRISMARINE_SHARD: 60 - PRISMARINE_CRYSTALS: 90 - NAUTILUS_SHELL: 300 - HEART_OF_THE_SEA: 2500 - SHULKER_SHELL: 800 - ECHO_SHARD: 600 - TOTEM_OF_UNDYING: 4000 - DRAGON_BREATH: 500 - TURTLE_SCUTE: 120 - ARMADILLO_SCUTE: 60 - HONEYCOMB: 40 - SADDLE: 300 - NAME_TAG: 400 - POTION: 60 - SPLASH_POTION: 80 - LINGERING_POTION: 120 - APPLE: 25 - MELON: 35 - SWEET_BERRIES: 10 - GLOW_BERRIES: 30 - COCOA_BEANS: 20 - BAMBOO: 5 - CACTUS: 10 - BROWN_MUSHROOM: 15 - RED_MUSHROOM: 15 - SEA_PICKLE: 25 - SPONGE: 200 - NETHER_WART: 30 - CLAY_BALL: 8 - SNOWBALL: 3 - DIRT: 2 - OBSIDIAN: 80 - NETHERRACK: 3 - SOUL_SAND: 15 - END_STONE: 20 - GLOWSTONE_DUST: 40 - MAGMA_CREAM: 90 - OAK_BOAT: 200 - OAK_CHEST_BOAT: 600 + WHEAT: 20.0 + CARROT: 15.0 + POTATO: 15.0 + BEETROOT: 15.0 + SUGAR_CANE: 10.0 + SUGAR: 15.0 + PUMPKIN: 30.0 + MELON_SLICE: 5.0 + BREAD: 30.0 + COOKED_BEEF: 40.0 + COOKED_COD: 30.0 + CAKE: 200.0 + GOLDEN_APPLE: 1500.0 + EGG: 10.0 + HAY_BLOCK: 180.0 + COD: 20.0 + SALMON: 30.0 + TROPICAL_FISH: 50.0 + PUFFERFISH: 40.0 + KELP: 3.0 + OAK_LOG: 15.0 + SPRUCE_LOG: 15.0 + BIRCH_LOG: 15.0 + DARK_OAK_LOG: 15.0 + ACACIA_LOG: 15.0 + JUNGLE_LOG: 15.0 + CHERRY_LOG: 20.0 + STONE: 5.0 + COBBLESTONE: 3.0 + GRANITE: 4.0 + DIORITE: 4.0 + ANDESITE: 4.0 + DEEPSLATE: 6.0 + SAND: 3.0 + GRAVEL: 3.0 + COAL: 40.0 + CHARCOAL: 30.0 + RAW_IRON: 60.0 + RAW_COPPER: 30.0 + RAW_GOLD: 120.0 + FLINT: 10.0 + IRON_INGOT: 90.0 + COPPER_INGOT: 40.0 + GOLD_INGOT: 180.0 + IRON_NUGGET: 10.0 + GOLD_NUGGET: 20.0 + DIAMOND: 1000.0 + EMERALD: 600.0 + AMETHYST_SHARD: 100.0 + QUARTZ: 80.0 + LAPIS_LAZULI: 60.0 + REDSTONE: 30.0 + LEATHER: 40.0 + WHITE_WOOL: 20.0 + STRING: 15.0 + BEEF: 25.0 + PORKCHOP: 25.0 + CHICKEN: 20.0 + MUTTON: 20.0 + BONE: 20.0 + BONE_MEAL: 8.0 + GUNPOWDER: 50.0 + SPIDER_EYE: 30.0 + ROTTEN_FLESH: 5.0 + FEATHER: 15.0 + INK_SAC: 25.0 + GLOW_INK_SAC: 90.0 + SLIME_BALL: 40.0 + RABBIT: 20.0 + RABBIT_HIDE: 15.0 + RABBIT_FOOT: 150.0 + ENDER_PEARL: 250.0 + BLAZE_ROD: 200.0 + GHAST_TEAR: 400.0 + PHANTOM_MEMBRANE: 180.0 + NETHER_STAR: 5000.0 + PRISMARINE_SHARD: 60.0 + PRISMARINE_CRYSTALS: 90.0 + NAUTILUS_SHELL: 300.0 + HEART_OF_THE_SEA: 2500.0 + SHULKER_SHELL: 800.0 + ECHO_SHARD: 600.0 + TOTEM_OF_UNDYING: 4000.0 + DRAGON_BREATH: 500.0 + TURTLE_SCUTE: 120.0 + ARMADILLO_SCUTE: 60.0 + HONEYCOMB: 40.0 + SADDLE: 300.0 + NAME_TAG: 400.0 + POTION: 60.0 + SPLASH_POTION: 80.0 + LINGERING_POTION: 120.0 + APPLE: 25.0 + MELON: 35.0 + SWEET_BERRIES: 10.0 + GLOW_BERRIES: 30.0 + COCOA_BEANS: 20.0 + BAMBOO: 5.0 + CACTUS: 10.0 + BROWN_MUSHROOM: 15.0 + RED_MUSHROOM: 15.0 + SEA_PICKLE: 25.0 + SPONGE: 200.0 + NETHER_WART: 30.0 + CLAY_BALL: 8.0 + SNOWBALL: 3.0 + DIRT: 2.0 + OBSIDIAN: 80.0 + NETHERRACK: 3.0 + SOUL_SAND: 15.0 + END_STONE: 20.0 + GLOWSTONE_DUST: 40.0 + MAGMA_CREAM: 90.0 + OAK_BOAT: 200.0 + OAK_CHEST_BOAT: 600.0 # This is the COMPLETE table. BentoBox REPLACES map settings from config # rather than merging them, so an entry deleted here is a good that can no # longer be sold at all - not a fallback to the built-in price. diff --git a/src/test/java/world/bentobox/tradewinds/SettingsTest.java b/src/test/java/world/bentobox/tradewinds/SettingsTest.java index f994816..37471a6 100644 --- a/src/test/java/world/bentobox/tradewinds/SettingsTest.java +++ b/src/test/java/world/bentobox/tradewinds/SettingsTest.java @@ -4,6 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.HashMap; +import java.util.Map; + import org.bukkit.Material; import org.bukkit.block.Biome; import org.junit.jupiter.api.BeforeEach; @@ -113,6 +116,61 @@ void testEveryPricedMaterialIsReal() { } } + @Test + void testWholeNumberConfigPricesDoNotExplode() { + // The playtest crash of 2026-08-03: BentoBox's YAML deserializer promotes + // Integer to Long but NOT to Double, so a price written "20" instead of + // "20.0" arrives as an Integer inside a Map. Generics are + // erased, so nothing complains until the first read throws + // ClassCastException - at the trader, mid-dialog. + Map raw = new HashMap<>(); + raw.put("WHEAT", 20); // Integer, as YAML "20" gives + raw.put("DIAMOND", 1000L); // Long, as BentoBox's Long promotion gives + raw.put("COAL", 40.0); // already a Double + raw.put("BEDROCK", "nonsense"); // not a number at all + @SuppressWarnings({ "unchecked", "rawtypes" }) + Map pretendDoubles = (Map) raw; + settings.setBasePrices(pretendDoubles); + + // Reading these must not throw, and must give the right numbers + assertEquals(20.0, settings.getBasePrices().get("WHEAT")); + assertEquals(1000.0, settings.getBasePrices().get("DIAMOND")); + assertEquals(40.0, settings.getBasePrices().get("COAL")); + assertFalse(settings.getBasePrices().containsKey("BEDROCK"), "Junk values are dropped, not kept"); + // And every value really is a Double now + settings.getBasePrices().values().forEach(value -> assertEquals(Double.class, value.getClass())); + } + + @Test + void testEveryNumericMapSettingIsCoerced() { + // Same trap, same fix, for every Map the config carries + Map raw = new HashMap<>(); + raw.put("COAL", 8); + @SuppressWarnings({ "unchecked", "rawtypes" }) + Map ints = (Map) raw; + settings.setFuelValues(ints); + settings.setCrimeBounties(ints); + settings.setEdgeOverrides(ints); + assertEquals(8.0, settings.getFuelValues().get("COAL")); + assertEquals(8.0, settings.getCrimeBounties().get("COAL")); + assertEquals(8.0, settings.getEdgeOverrides().get("COAL")); + } + + @Test + void testShippedConfigPricesAreWrittenAsDecimals() throws Exception { + // Belt and braces: the coercion above makes this cosmetic, but a config + // that reads "20.0" tells an admin the field is a decimal + var config = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration( + new java.io.InputStreamReader( + getClass().getClassLoader().getResourceAsStream("config.yml"))); + var section = config.getConfigurationSection("economy.base-prices"); + for (String key : section.getKeys(false)) { + assertTrue(section.get(key) instanceof Double, + key + " is written as " + section.get(key).getClass().getSimpleName() + + " - write it with a decimal point"); + } + } + @Test void testShippedConfigCarriesEveryPrice() throws Exception { // BentoBox REPLACES map settings from config.yml instead of merging them From 8d6ef7308baf61c3cec83aa378bec73eb5bc9a39 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 19:39:18 -0700 Subject: [PATCH 087/112] Fix a 30-slot oak boat, and show what you are selling Two playtest reports. An oak boat carried 30 slots because the shipped config.yml had every value in boats.ranks multiplied by ten - cargo slot counts caught up in the whole-coin money migration. Slots are not money. BentoBox replaces map settings from config rather than merging them, so the wrong values beat the correct code defaults, and since every unit test reads the code default the build stayed green. That is the second time config/code drift has shipped a broken game this way, after base-prices shipping 27 of 112 entries. So the real fix is not the data: ConfigAgreementTest now walks every @ConfigEntry map in Settings by reflection and asserts config.yml agrees with the code default, key for key and value for value, comparing numbers by value so 20 and 20.0 are the same setting. I corrupted two entries deliberately to confirm it names both rather than passing vacuously. Second, with NBT preserved the sell page showed three identical "Iron Sword" rows at different prices and nothing said which was the enchanted one. Paper's dialog API can render item icons - DialogBody.item with a real hover tooltip - but only in the body, never in a button label. So the sell page and the secondhand shelf now show one icon per row in the same order as the buttons, tooltips on, and the buttons carry a short marker instead: a star for enchanted, "(worn)" for damaged, or the item's given name where it has one. 297 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 8 ++ docs/PROGRESS.md | 34 +++++ .../tradewinds/economy/TradeDialog.java | 77 +++++++++-- src/main/resources/config.yml | 40 +++--- src/main/resources/locales/en-US.yml | 4 + .../tradewinds/ConfigAgreementTest.java | 121 ++++++++++++++++++ 6 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 src/test/java/world/bentobox/tradewinds/ConfigAgreementTest.java diff --git a/TESTING.md b/TESTING.md index a0ddb57..75c21fd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -100,6 +100,14 @@ noise. occupy separate slots, and only the mined 20 can be withdrawn. - [ ] Hold gestures: left-click withdraws, shift-click sends coal to the fuel row, right-click selects for the TNT. +- [ ] **Boat capacity matches the ladder.** An oak boat's hold says **3 slots**, + a bamboo raft 2, a pale oak chest boat 21. If any reads a multiple of ten, + `config.yml` has drifted from the code defaults - `ConfigAgreementTest` + should have caught it. +- [ ] **The sell page tells identical goods apart.** With one enchanted and two + plain iron swords aboard, the sell page shows an item icon per row: hover it + for the real tooltip (enchantments, damage), and the enchanted row is marked + and priced higher. - [ ] **NBT survives the hold, and a restart.** Load an enchanted, a renamed and a half-broken tool into the hold. Each takes its own slot and keeps its name, enchantments and damage bar. Restart the server: all three are still diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2a53ef2..3d8562c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,40 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Playtest fixes — config drift and telling goods apart (2026-08-03) + +**An oak boat carried 30 slots.** The shipped `config.yml` had every value in +`boats.ranks` multiplied by ten: cargo SLOT counts caught up in the whole-coin +money migration. Slots are not money. Because BentoBox **replaces** map settings +from config rather than merging them, the wrong values beat the correct code +defaults - and every unit test reads the code default, so the build stayed green. + +That is the **second** time config/code drift shipped a broken game this way (the +first was `economy.base-prices` at 27 of 112 entries). So the fix is not just the +data: **`ConfigAgreementTest` now walks every `@ConfigEntry` map in `Settings` by +reflection and asserts `config.yml` agrees with the code default**, key for key +and value for value, comparing numbers by value so `20` and `20.0` are the same +setting. Verified by deliberately corrupting two entries and watching it name both. +A second test asserts the ranks ladder is 20 rungs of 2-21, which is the shape +that a money migration would break again. + +**ClassCastException at the trader.** Integer cannot be cast to Double, from +`base-prices` written as `20` rather than `20.0` - see the commit; fixed both by +writing decimals and by coercing every numeric map in `Settings.asDoubles()`. + +**Three identical "Iron Sword" rows on the sell page.** With NBT preserved, the +enchanted sword and the two plain ones were three rows of identical text at +different prices, and nothing said which was which. The sell page (and the +secondhand shelf) now render **one `ItemDialogBody` per row, in the same order as +the buttons, with `showTooltip(true)`** - so hovering gives the item's real +tooltip, enchantments and all. Buttons are far too narrow for an enchantment list, +so they get a short marker instead (`✦` for enchanted, `(worn)` for damaged, or +the item's given name if it has one). + +Paper's dialog API does support item icons - `DialogBody.item(ItemStack)` with a +description and a real tooltip - but only in the dialog **body**, never in a +button label. Hence icons above, markers on the buttons. + ## Stage 7.5 Phase 7 — the secondhand shelf (2026-08-03) Notable goods sold to a trader go back out for sale instead of vanishing, so the diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 1ffcef4..18322d8 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -150,9 +150,10 @@ public void openSell(Player player, IslandSpec spec) { openMain(player, spec); return; } + List shown = offers.subList(0, Math.min(MAX_ROWS, offers.size())); List buttons = new ArrayList<>(); - for (SellOffer offer : offers.subList(0, Math.min(MAX_ROWS, offers.size()))) { - String name = MarketService.pretty(offer.material()); + for (SellOffer offer : shown) { + String name = itemLabel(player, offer.item()); Component each = ui(player, "market.sell-tooltip-each", "[price]", Money.format(addon, offer.unitPrice()), "[depth]", depthOf(spec, offer)); buttons.add(button(ui(player, "market.sell-one", "[material]", name), each, @@ -164,15 +165,51 @@ public void openSell(Player player, IslandSpec spec) { "[price]", Money.format(addon, offer.total())), each, () -> sellThenReopen(player, spec, offer.item(), Integer.MAX_VALUE))); } - List body = new ArrayList<>(List.of(ui(player, "market.selling-body", NO_VARS), - statusLine(player))); + // One icon per row, in the SAME ORDER as the buttons below, each carrying + // the item's real hover tooltip. Three identical "Iron Sword" rows told a + // player nothing about which was the enchanted one (playtest 2026-08-03). + List body = new ArrayList<>(); + body.add(DialogBody.plainMessage(ui(player, "market.selling-body", NO_VARS))); + body.add(DialogBody.plainMessage(statusLine(player))); + for (SellOffer offer : shown) { + body.add(DialogBody.item(offer.item()) + .description(DialogBody.plainMessage(ui(player, "market.sell-item-line", "[material]", + itemLabel(player, offer.item()), "[amount]", String.valueOf(offer.amount()), + "[price]", Money.format(addon, offer.unitPrice())))) + .showTooltip(true).showDecorations(true).build()); + } if (offers.size() > MAX_ROWS) { - body.add(ui(player, "market.selling-truncated", "[number]", String.valueOf(MAX_ROWS))); + body.add(DialogBody.plainMessage(ui(player, "market.selling-truncated", "[number]", + String.valueOf(MAX_ROWS)))); } - show(player, ui(player, "market.selling-title", "[name]", spec.name()), body, buttons, + showBodies(player, ui(player, "market.selling-title", "[name]", spec.name()), body, buttons, backButton(player, spec), 3); } + /** + * A short label that tells two otherwise identical goods apart: its given + * name if it has one, else a marker for enchanted or worn gear. Buttons are + * too narrow for a full enchantment list - that is what the icon tooltip + * beside it is for. + */ + private String itemLabel(Player player, ItemStack item) { + User user = User.getInstance(player); + String material = MarketService.pretty(item.getType()); + var meta = item.getItemMeta(); + if (meta != null && meta.hasDisplayName()) { + return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(meta.displayName()); + } + if (!item.getEnchantments().isEmpty()) { + return user.getTranslation("tradewinds.market.item-enchanted", "[material]", material); + } + if (meta instanceof org.bukkit.inventory.meta.Damageable damaged && damaged.hasDamage() + && damaged.getDamage() > 0) { + return user.getTranslation("tradewinds.market.item-worn", "[material]", material); + } + return material; + } + /** * The secondhand shelf: notable goods other sailors sold on, shipped here by * their traders. One button per listing. @@ -185,21 +222,29 @@ public void openShelf(Player player, IslandSpec spec) { return; } List buttons = new ArrayList<>(); + List body = new ArrayList<>(); + body.add(DialogBody.plainMessage(ui(player, "market.shelf-body", NO_VARS))); + body.add(DialogBody.plainMessage(statusLine(player))); for (int i = 0; i < Math.min(MAX_ROWS, shelf.size()); i++) { ItemStack item = shelf.get(i); final int index = i; String price = addon.getMarketService().shelfPrice(item) .map(value -> Money.format(addon, value)).orElse("-"); - buttons.add(button(ui(player, "market.shelf-buy", "[material]", MarketService.pretty(item.getType()), + // Secondhand goods are notable by definition, so the icon and its real + // tooltip are the whole point of the page + body.add(DialogBody.item(item) + .description(DialogBody.plainMessage(ui(player, "market.shelf-item-line", "[material]", + itemLabel(player, item), "[price]", price))) + .showTooltip(true).showDecorations(true).build()); + buttons.add(button(ui(player, "market.shelf-buy", "[material]", itemLabel(player, item), "[price]", price), ui(player, "market.shelf-buy-tooltip", "[details]", details(item)), () -> { addon.getMarketService().buyFromShelf(player, spec, index); openShelf(player, spec); })); } - show(player, ui(player, "market.shelf-title", "[name]", spec.name()), - new ArrayList<>(List.of(ui(player, "market.shelf-body", NO_VARS), statusLine(player))), - buttons, backButton(player, spec), 2); + showBodies(player, ui(player, "market.shelf-title", "[name]", spec.name()), body, buttons, + backButton(player, spec), 2); } /** @@ -521,7 +566,17 @@ private ActionButton button(Component label, Component tooltip, Runnable action) private void show(Player player, Component title, List bodyLines, List buttons, ActionButton exitButton, int columns) { - List body = bodyLines.stream().map(line -> (DialogBody) DialogBody.plainMessage(line)).toList(); + showBodies(player, title, + bodyLines.stream().map(line -> (DialogBody) DialogBody.plainMessage(line)).toList(), buttons, + exitButton, columns); + } + + /** + * As {@link #show} but with the body built by the caller, so it can carry + * item icons and not just text. + */ + private void showBodies(Player player, Component title, List body, List buttons, + ActionButton exitButton, int columns) { Dialog dialog = Dialog.create(factory -> factory.empty() .base(DialogBase.builder(title).body(body).build()) .type(DialogType.multiAction(buttons).exitAction(exitButton).columns(columns).build())); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 05fd119..7b0b9e7 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -467,26 +467,26 @@ boats: # by slots; shops list only boats bigger than yours, up to the island's tech # (rank <= tech level x ranks-per-tech-level). Crafting ignores tech gates. ranks: - BAMBOO_RAFT: 20 - OAK_BOAT: 30 - SPRUCE_BOAT: 40 - BAMBOO_CHEST_RAFT: 50 - BIRCH_BOAT: 60 - OAK_CHEST_BOAT: 70 - JUNGLE_BOAT: 80 - SPRUCE_CHEST_BOAT: 90 - ACACIA_BOAT: 100 - BIRCH_CHEST_BOAT: 110 - DARK_OAK_BOAT: 120 - JUNGLE_CHEST_BOAT: 130 - MANGROVE_BOAT: 140 - ACACIA_CHEST_BOAT: 150 - CHERRY_BOAT: 160 - DARK_OAK_CHEST_BOAT: 170 - PALE_OAK_BOAT: 180 - MANGROVE_CHEST_BOAT: 190 - CHERRY_CHEST_BOAT: 200 - PALE_OAK_CHEST_BOAT: 210 + BAMBOO_RAFT: 2 + OAK_BOAT: 3 + SPRUCE_BOAT: 4 + BAMBOO_CHEST_RAFT: 5 + BIRCH_BOAT: 6 + OAK_CHEST_BOAT: 7 + JUNGLE_BOAT: 8 + SPRUCE_CHEST_BOAT: 9 + ACACIA_BOAT: 10 + BIRCH_CHEST_BOAT: 11 + DARK_OAK_BOAT: 12 + JUNGLE_CHEST_BOAT: 13 + MANGROVE_BOAT: 14 + ACACIA_CHEST_BOAT: 15 + CHERRY_BOAT: 16 + DARK_OAK_CHEST_BOAT: 17 + PALE_OAK_BOAT: 18 + MANGROVE_CHEST_BOAT: 19 + CHERRY_CHEST_BOAT: 20 + PALE_OAK_CHEST_BOAT: 21 # Boat shop price = this x slots squared. Bamboo Raft 1000, Pale Oak Chest 110250. price-per-slot-squared: 250.0 # Boat ranks sold at an island = its tech level x this (TL7 sells all 20). diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 5cf2005..d3bc898 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -173,6 +173,7 @@ tradewinds: shelf-tooltip: "Goods other sailors sold on, shipped here by their traders." shelf-title: "Secondhand at [name]" shelf-body: "Odds and ends the trader took in. Sold as seen." + shelf-item-line: "[material] - $[price]" shelf-buy: "[material] - $[price]" shelf-buy-tooltip: "[details]" report: "Harbour report" @@ -194,6 +195,9 @@ tradewinds: sell-one: "[material] x1" sell-batch: "[material] x[amount]" sell-all: "All [amount] - $[price]" + item-enchanted: "[material] ✦" + item-worn: "[material] (worn)" + sell-item-line: "[material] x[amount] - $[price] each" sell-tooltip-each: "$[price] each. This port will take about [depth] more at that price." buy-one: "[material] x1 - $[price]" buy-batch: "x[amount] - $[price]" diff --git a/src/test/java/world/bentobox/tradewinds/ConfigAgreementTest.java b/src/test/java/world/bentobox/tradewinds/ConfigAgreementTest.java new file mode 100644 index 0000000..44a5492 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/ConfigAgreementTest.java @@ -0,0 +1,121 @@ +package world.bentobox.tradewinds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStreamReader; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.api.configuration.ConfigEntry; + +/** + * The shipped {@code config.yml} must agree with {@link Settings}' own defaults. + *

+ * This is not tidiness. BentoBox replaces map settings from config rather + * than merging them ({@code YamlDatabaseHandler.deserializeMap}), so whatever the + * shipped config says WINS over the code default - and every unit test in this + * suite reads the code default, so the two can disagree indefinitely with a green + * build and a broken game. It has happened twice: + *

    + *
  • {@code economy.base-prices} shipped 27 of 112 entries, so ~50 goods could + * not be sold at all on a real server.
  • + *
  • {@code boats.ranks} shipped every value multiplied by ten - cargo SLOT + * counts caught up in the whole-coin money migration - so an oak boat carried 30 + * slots instead of 3.
  • + *
+ * Both were invisible to the tests and obvious in play. This test closes that gap + * for every map setting at once, present and future. + * + * @author tastybento + */ +class ConfigAgreementTest extends CommonTestSetup { + + private Settings settings; + private YamlConfiguration config; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + settings = new Settings(); + config = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("config.yml"))); + } + + @Test + void testEveryMapSettingMatchesItsCodeDefault() throws Exception { + List problems = new ArrayList<>(); + int checked = 0; + for (Field field : Settings.class.getDeclaredFields()) { + ConfigEntry entry = field.getAnnotation(ConfigEntry.class); + if (entry == null || !Map.class.isAssignableFrom(field.getType())) { + continue; + } + checked++; + field.setAccessible(true); + Map expected = (Map) field.get(settings); + ConfigurationSection section = config.getConfigurationSection(entry.path()); + Map actual = new TreeMap<>(); + if (section != null) { + section.getKeys(false).forEach(key -> actual.put(key, section.get(key))); + } + compare(entry.path(), expected, actual, problems); + } + assertTrue(checked > 0, "Found no map settings to check - has Settings changed shape?"); + assertTrue(problems.isEmpty(), "config.yml disagrees with the code defaults:\n" + + String.join("\n", problems)); + } + + private void compare(String path, Map expected, Map actual, List problems) { + for (Map.Entry want : expected.entrySet()) { + String key = String.valueOf(want.getKey()); + if (!actual.containsKey(key)) { + problems.add(" " + path + ": missing '" + key + "' (code says " + want.getValue() + ")"); + continue; + } + if (!sameValue(want.getValue(), actual.get(key))) { + problems.add(" " + path + "." + key + ": config says " + actual.get(key) + ", code says " + + want.getValue()); + } + } + for (String key : actual.keySet()) { + if (!expected.containsKey(key)) { + problems.add(" " + path + ": config has '" + key + "' which the code default does not"); + } + } + } + + /** + * Numbers compare by value, not by boxed type: YAML types a number by how it + * is written, so 20 and 20.0 are the same setting written two ways. + */ + private boolean sameValue(Object expected, Object actual) { + if (expected instanceof Number want && actual instanceof Number got) { + return Math.abs(want.doubleValue() - got.doubleValue()) < 1e-9; + } + return String.valueOf(expected).equals(String.valueOf(actual)); + } + + @Test + void testBoatRanksAreSlotsNotMoney() { + // The specific shape of the ranks bug: slot counts are small integers, and + // the ladder runs 2..21. Anything an order of magnitude out means a money + // migration has been applied to a table that is not money. + Map ranks = settings.getBoatRanks(); + assertEquals(20, ranks.size(), "The ladder has 20 rungs"); + assertEquals(2, ranks.get("BAMBOO_RAFT"), "The smallest hull carries 2"); + assertEquals(3, ranks.get("OAK_BOAT"), "An oak boat carries 3, not 30"); + assertEquals(21, ranks.get("PALE_OAK_CHEST_BOAT"), "The top hull carries 21"); + ranks.forEach((material, slots) -> assertTrue(slots >= 1 && slots <= 21, + material + " has " + slots + " slots, which is not on a 2-21 ladder")); + } +} From 6303c6da65e338036a9d090f68e73a6172a76d9d Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 20:40:41 -0700 Subject: [PATCH 088/112] Redesign selling as pick-then-quantity, and guard locale keys Three reports from the same screenshot. The sell page duplicated every good: an icon row above, three buttons below. Icon-beside-buttons is not achievable - Paper renders items only in a dialog body, never in a button label, and the two are separate regions - so selling is now two pages. The list gives one row per distinct good; picking one shows that good's icon at 32px with its real hover tooltip, the live unit price, the remaining depth, and 1 / 16 / all. Selling reopens the item page rather than the list, so the price and depth visibly move as the port fills up, which is the whole reason to sell in increments. A button read "tradewinds.market.item-enchanted x1" because itemLabel asked for that key while the dialog helper prefixes tradewinds.ui. - a missing key does not throw, BentoBox renders the key itself. That is the third locale failure of this shape, after key names that YAML read as booleans and a duplicate that was silently dropped, so LocaleKeyTest now scans every source file for locale keys and asserts each resolves to a leaf string. It strips comments and @ConfigEntry paths, since both quote keys that are not lookups, skips concatenated keys, and asserts the four runtime-built page keys explicitly rather than passing over them in silence. Confirmed by renaming a key and watching it fail. A uiText() helper now shares the prefix with ui() so the two cannot drift apart again. 299 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 11 +- docs/PROGRESS.md | 25 ++++ .../tradewinds/economy/TradeDialog.java | 114 +++++++++++++----- src/main/resources/locales/en-US.yml | 11 +- .../bentobox/tradewinds/LocaleKeyTest.java | 101 ++++++++++++++++ 5 files changed, 222 insertions(+), 40 deletions(-) create mode 100644 src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java diff --git a/TESTING.md b/TESTING.md index 75c21fd..408255d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -105,9 +105,14 @@ noise. `config.yml` has drifted from the code defaults - `ConfigAgreementTest` should have caught it. - [ ] **The sell page tells identical goods apart.** With one enchanted and two - plain iron swords aboard, the sell page shows an item icon per row: hover it - for the real tooltip (enchantments, damage), and the enchanted row is marked - and priced higher. + plain iron swords aboard, the sell list shows two rows: "Iron Sword ✦ x1" + and "Iron Sword x2", the enchanted one priced higher. No row shows a raw + locale key like `tradewinds.ui.market.item-enchanted`. +- [ ] **Picking a good shows it and prices it live.** Click a row: its icon + appears (hover for the real tooltip - enchantments, damage), with the unit + price and how much more the port will take. Sell 1: the page stays open and + the price and depth **move**. Sell the last one and you drop back to the + list. - [ ] **NBT survives the hold, and a restart.** Load an enchanted, a renamed and a half-broken tool into the hold. Each takes its own slot and keeps its name, enchantments and damage bar. Restart the server: all three are still diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3d8562c..e1247e0 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -24,6 +24,31 @@ that a money migration would break again. `base-prices` written as `20` rather than `20.0` - see the commit; fixed both by writing decimals and by coercing every numeric map in `Settings.asDoubles()`. +**The sell page redesigned: pick a good, then a quantity (2026-08-03).** The +first attempt put an icon per row above a wall of three buttons per good, which +duplicated every good twice over and still could not put the icon *beside* its +buttons - Paper's dialog API renders items only in the **body**, never in a +button label, and body and buttons are separate regions. So selling is two pages: +- `openSell` lists one row per distinct good, labelled and priced. +- `openSellItem` shows that good's icon at 32px with its real hover tooltip, the + live unit price, the remaining depth, and 1 / 16 / all. + +Selling reopens the item page rather than the list, so **the price and depth +visibly move as the port fills up** - which is the entire reason to sell in +increments rather than all at once. + +**A locale key rendered as itself.** `itemLabel` asked for +`tradewinds.market.item-enchanted` while the dialog helper prefixes +`tradewinds.ui.` - so the button read "tradewinds.market.item-enchanted x1". +A missing key does not throw; BentoBox renders the key. That is the **third** +locale failure of this kind after YAML-boolean key names and a silently dropped +duplicate, so **`LocaleKeyTest` now scans every `.java` source for locale keys and +asserts each resolves to a leaf string in `en-US.yml`**. It strips comments and +`@ConfigEntry` paths (both quote keys that are not lookups), ignores concatenated +keys, and asserts the four runtime-built page keys explicitly rather than skipping +them. Verified by renaming a key and watching it fail. A new `uiText()` helper +shares the prefix with `ui()` so the two cannot drift again. + **Three identical "Iron Sword" rows on the sell page.** With NBT preserved, the enchanted sword and the two plain ones were three rows of identical text at different prices, and nothing said which was which. The sell page (and the diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 18322d8..60396cc 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -21,6 +21,7 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.tradewinds.TradeWinds; import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.travel.CargoStore; import world.bentobox.tradewinds.travel.FuelWarning; /** @@ -150,40 +151,79 @@ public void openSell(Player player, IslandSpec spec) { openMain(player, spec); return; } - List shown = offers.subList(0, Math.min(MAX_ROWS, offers.size())); + // One row per DISTINCT good, not three. The quantity buttons live on the + // next page, where the item's own icon and a live price sit beside them - + // an icon cannot go in a button label, and repeating every good three + // times across a wall of buttons told a player less, not more + // (playtest 2026-08-03). List buttons = new ArrayList<>(); - for (SellOffer offer : shown) { - String name = itemLabel(player, offer.item()); - Component each = ui(player, "market.sell-tooltip-each", "[price]", - Money.format(addon, offer.unitPrice()), "[depth]", depthOf(spec, offer)); - buttons.add(button(ui(player, "market.sell-one", "[material]", name), each, - () -> sellThenReopen(player, spec, offer.item(), 1))); - buttons.add(button(ui(player, "market.sell-batch", "[material]", name, "[amount]", - String.valueOf(MID_BATCH)), each, - () -> sellThenReopen(player, spec, offer.item(), MID_BATCH))); - buttons.add(button(ui(player, "market.sell-all", "[amount]", String.valueOf(offer.amount()), - "[price]", Money.format(addon, offer.total())), each, - () -> sellThenReopen(player, spec, offer.item(), Integer.MAX_VALUE))); - } - // One icon per row, in the SAME ORDER as the buttons below, each carrying - // the item's real hover tooltip. Three identical "Iron Sword" rows told a - // player nothing about which was the enchanted one (playtest 2026-08-03). - List body = new ArrayList<>(); - body.add(DialogBody.plainMessage(ui(player, "market.selling-body", NO_VARS))); - body.add(DialogBody.plainMessage(statusLine(player))); - for (SellOffer offer : shown) { - body.add(DialogBody.item(offer.item()) - .description(DialogBody.plainMessage(ui(player, "market.sell-item-line", "[material]", - itemLabel(player, offer.item()), "[amount]", String.valueOf(offer.amount()), - "[price]", Money.format(addon, offer.unitPrice())))) - .showTooltip(true).showDecorations(true).build()); + for (SellOffer offer : offers.subList(0, Math.min(MAX_ROWS, offers.size()))) { + buttons.add(button( + ui(player, "market.sell-pick", "[material]", itemLabel(player, offer.item()), "[amount]", + String.valueOf(offer.amount()), "[price]", Money.format(addon, offer.unitPrice())), + ui(player, "market.sell-pick-tooltip", "[price]", Money.format(addon, offer.unitPrice()), + "[depth]", depthOf(spec, offer)), + () -> openSellItem(player, spec, offer.item()))); } + List body = new ArrayList<>(List.of(ui(player, "market.selling-body", NO_VARS), + statusLine(player))); if (offers.size() > MAX_ROWS) { - body.add(DialogBody.plainMessage(ui(player, "market.selling-truncated", "[number]", - String.valueOf(MAX_ROWS)))); + body.add(ui(player, "market.selling-truncated", "[number]", String.valueOf(MAX_ROWS))); + } + show(player, ui(player, "market.selling-title", "[name]", spec.name()), body, buttons, + backButton(player, spec), 2); + } + + /** + * The quantity page for ONE good: its icon with the real hover tooltip, the + * live unit price and how much more this port will absorb, then 1 / 16 / all. + *

+ * Reopened after every sale, so the price and the remaining depth update as + * the port fills up - which is the whole point of selling in increments. + */ + public void openSellItem(Player player, IslandSpec spec, ItemStack item) { + // Re-resolve against the hold: the price moves as you sell, and the good + // may be gone entirely + SellOffer offer = sellOffers(player, spec).stream() + .filter(candidate -> CargoStore.stacksTogether(candidate.item(), item)).findFirst() + .orElse(null); + if (offer == null) { + openSell(player, spec); + return; } + String each = Money.format(addon, offer.unitPrice()); + List body = new ArrayList<>(); + body.add(DialogBody.item(offer.item()) + .description(DialogBody.plainMessage(ui(player, "market.sell-item-line", "[material]", + itemLabel(player, offer.item()), "[amount]", String.valueOf(offer.amount()), + "[price]", each))) + .showTooltip(true).showDecorations(true).width(32).height(32).build()); + body.add(DialogBody.plainMessage(ui(player, "market.sell-item-depth", "[depth]", + depthOf(spec, offer)))); + body.add(DialogBody.plainMessage(statusLine(player))); + + int batch = Math.min(MID_BATCH, offer.amount()); + List buttons = new ArrayList<>(); + buttons.add(button(ui(player, "market.sell-qty-one", "[price]", each), + ui(player, "market.sell-qty-tooltip", NO_VARS), + () -> sellThenReopen(player, spec, offer.item(), 1))); + if (batch > 1) { + buttons.add(button( + ui(player, "market.sell-qty-batch", "[amount]", String.valueOf(batch), "[price]", + Money.format(addon, batch * offer.unitPrice())), + ui(player, "market.sell-qty-tooltip", NO_VARS), + () -> sellThenReopen(player, spec, offer.item(), batch))); + } + buttons.add(button( + ui(player, "market.sell-qty-all", "[amount]", String.valueOf(offer.amount()), "[price]", + Money.format(addon, offer.total())), + ui(player, "market.sell-qty-tooltip", NO_VARS), + () -> sellThenReopen(player, spec, offer.item(), Integer.MAX_VALUE))); + showBodies(player, ui(player, "market.selling-title", "[name]", spec.name()), body, buttons, - backButton(player, spec), 3); + button(ui(player, "market.back", NO_VARS), ui(player, "market.back-tooltip", NO_VARS), + () -> openSell(player, spec)), + 3); } /** @@ -193,7 +233,6 @@ public void openSell(Player player, IslandSpec spec) { * beside it is for. */ private String itemLabel(Player player, ItemStack item) { - User user = User.getInstance(player); String material = MarketService.pretty(item.getType()); var meta = item.getItemMeta(); if (meta != null && meta.hasDisplayName()) { @@ -201,11 +240,11 @@ private String itemLabel(Player player, ItemStack item) { .serialize(meta.displayName()); } if (!item.getEnchantments().isEmpty()) { - return user.getTranslation("tradewinds.market.item-enchanted", "[material]", material); + return uiText(player, "market.item-enchanted", "[material]", material); } if (meta instanceof org.bukkit.inventory.meta.Damageable damaged && damaged.hasDamage() && damaged.getDamage() > 0) { - return user.getTranslation("tradewinds.market.item-worn", "[material]", material); + return uiText(player, "market.item-worn", "[material]", material); } return material; } @@ -282,7 +321,7 @@ private String depthOf(IslandSpec spec, SellOffer offer) { private void sellThenReopen(Player player, IslandSpec spec, ItemStack item, int amount) { addon.getMarketService().sell(player, spec, item, amount); - openSell(player, spec); + openSellItem(player, spec, item); } /** @@ -527,6 +566,15 @@ private Component ui(Player player, String key, String... variables) { return User.getInstance(player).getTranslationAsComponent("tradewinds.ui." + key, variables); } + /** + * The same key space as {@link #ui} but as plain text, for values that get + * substituted INTO another translation. Sharing the prefix matters: a hand + * written "tradewinds.market.x" silently renders as the key itself. + */ + private String uiText(Player player, String key, String... variables) { + return User.getInstance(player).getTranslation("tradewinds.ui." + key, variables); + } + /** * Write this port's current prices into the player's logbook. Sell prices, * because "where can I get a good price for this" is the question a trader diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index d3bc898..2dc16b3 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -192,13 +192,16 @@ tradewinds: outfitter-body: "Stores for the sailing life - food, fuel and gear" shipwright-title: "[name] - Shipwright" shipwright-body: "Upgrades only: your old boat is taken in and broken up, your cargo stays aboard. Smart sailors craft rare-wood boats instead of paying." - sell-one: "[material] x1" - sell-batch: "[material] x[amount]" - sell-all: "All [amount] - $[price]" + sell-pick: "[material] x[amount]" + sell-pick-tooltip: "$[price] each. This port will take about [depth] more at that price." + sell-qty-one: "Sell 1 - $[price]" + sell-qty-batch: "Sell [amount] - $[price]" + sell-qty-all: "Sell all [amount] - $[price]" + sell-qty-tooltip: "The price falls as this port fills up - watch it move." + sell-item-depth: "This port will take about [depth] more at this price." item-enchanted: "[material] ✦" item-worn: "[material] (worn)" sell-item-line: "[material] x[amount] - $[price] each" - sell-tooltip-each: "$[price] each. This port will take about [depth] more at that price." buy-one: "[material] x1 - $[price]" buy-batch: "x[amount] - $[price]" buy-tooltip-each: "$[price] each; limited by balance and hold space" diff --git a/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java new file mode 100644 index 0000000..3c09266 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/LocaleKeyTest.java @@ -0,0 +1,101 @@ +package world.bentobox.tradewinds; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +/** + * Every locale key the code asks for must exist in {@code en-US.yml}. + *

+ * A missing key does not throw - BentoBox renders the key itself, so the game + * shows a player "tradewinds.ui.market.item-enchanted" where a word should be. + * That has now happened three times: keys named after YAML 1.1 booleans, a + * duplicate key silently dropped, and a key written without the {@code ui.} + * prefix its helper adds. None of them failed a build. + *

+ * Only whole string literals are checked. Keys assembled by concatenation cannot + * be resolved statically and are skipped - see {@code DYNAMIC}. + * + * @author tastybento + */ +class LocaleKeyTest { + + /** Literal "tradewinds.x.y" keys anywhere in the source. */ + private static final Pattern DIRECT = Pattern.compile("\"(tradewinds\\.[a-z0-9-]+(?:\\.[a-z0-9-]+)+)\""); + + /** ui("x.y") and uiText("x.y") calls, which prepend "tradewinds.ui.". */ + private static final Pattern UI_CALL = Pattern.compile( + "\\bui(?:Text)?\\(player,\\s*\"([a-z0-9.-]+)\"\\s*[,)]"); + + /** + * Keys assembled at runtime ({@code "market." + page + "-title"}), which no + * static scan can see. Asserted explicitly by + * {@link #testKeysBuiltAtRuntimeExist} instead of being skipped in silence. + */ + private static final Set RUNTIME_BUILT = Set.of("tradewinds.ui.market.buying-title", + "tradewinds.ui.market.buying-body", "tradewinds.ui.market.selling-title", + "tradewinds.ui.market.selling-body"); + + /** Comments - a key quoted in javadoc is documentation, not a lookup. */ + private static final Pattern COMMENTS = Pattern.compile("/\\*.*?\\*/|//[^\n]*", Pattern.DOTALL); + + /** Config paths share the tradewinds. prefix but are not locale keys at all. */ + private static final Pattern CONFIG_PATHS = Pattern.compile("@ConfigEntry\\([^)]*\\)", Pattern.DOTALL); + + @Test + void testEveryLocaleKeyUsedInCodeExists() throws IOException { + YamlConfiguration locale = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("locales/en-US.yml"))); + Set used = new TreeSet<>(); + try (Stream sources = Files.walk(Path.of("src/main/java"))) { + for (Path file : sources.filter(p -> p.toString().endsWith(".java")).toList()) { + // Strip what only LOOKS like a lookup: javadoc quoting a key, and + // @ConfigEntry paths, which share the prefix but address config + String code = CONFIG_PATHS.matcher(COMMENTS.matcher(Files.readString(file)) + .replaceAll(" ")).replaceAll(" "); + collect(DIRECT.matcher(code), used, ""); + collect(UI_CALL.matcher(code), used, "tradewinds.ui."); + } + } + assertTrue(used.size() > 50, "Found only " + used.size() + " locale keys - has the scan broken?"); + + List missing = new ArrayList<>(); + for (String key : used) { + // A key must resolve to a leaf string, not a section: BentoBox cannot + // render a section, and a key that became a parent is a silent break + if (!(locale.get(key) instanceof String)) { + missing.add(" " + key + (locale.contains(key) ? " (a section, not a message)" : "")); + } + } + assertTrue(missing.isEmpty(), + "Locale keys used in code but not in en-US.yml:\n" + String.join("\n", missing)); + } + + @Test + void testKeysBuiltAtRuntimeExist() { + YamlConfiguration locale = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("locales/en-US.yml"))); + List missing = RUNTIME_BUILT.stream().filter(key -> !(locale.get(key) instanceof String)) + .sorted().toList(); + assertTrue(missing.isEmpty(), "Runtime-built locale keys missing: " + missing); + } + + private void collect(Matcher matcher, Set into, String prefix) { + while (matcher.find()) { + into.add(prefix + matcher.group(1)); + } + } +} From a3ef99e1f711ae74365ea5025b58eeafaf704064 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 21:21:14 -0700 Subject: [PATCH 089/112] Format money ourselves: "$27", never "$27.00 Dollars" Delegating display to the server economy's formatter was my call, and it loses to reality: TradeWinds rounds every price to a whole coin precisely so dense dialogs read clean, then handed the number to a formatter that put the cents back and added a currency name on top of the locale's own dollar sign. Money.format now renders whole coins with thousands separators and one symbol, configurable as economy.currency-symbol for servers whose money is gems or credits. All 23 hand-written dollar signs came out of the locale, so the symbol lives in exactly one place - after checking that every money variable in every locale string is produced by Money.format, and that item counts sharing the [amount] name were not caught by the strip. Vault still keeps the accounts; only display changed. MoneyTest pins the format, including that a stray fraction rounds away rather than resurfacing as cents, and that a null symbol falls back to $ rather than printing "null27". 302 tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 7 ++- TESTING.md | 3 + docs/PROGRESS.md | 17 ++++++ .../world/bentobox/tradewinds/Settings.java | 8 +++ .../bentobox/tradewinds/economy/Money.java | 26 ++++---- src/main/resources/config.yml | 4 ++ src/main/resources/locales/en-US.yml | 46 +++++++------- .../tradewinds/economy/MoneyTest.java | 61 +++++++++++++++++++ 8 files changed, 135 insertions(+), 37 deletions(-) create mode 100644 src/test/java/world/bentobox/tradewinds/economy/MoneyTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 3b6670e..4d86a9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,8 +78,11 @@ one-way-cargo rule below to trader-bought cargo only. fuel is fungible. **Match cargo with `isSimilar`, never by material.** - **Money is whole coins.** Buy prices `ceil`, sell prices `floor` — the direction stops the spread closing (nearest-rounding is exploitable). Never - format money by hand: `economy.Money.format(addon, amount)` asks the server's - Vault economy. + format money by hand: `economy.Money.format(addon, amount)` renders + "$1,728" — symbol from `economy.currency-symbol`, no cents. (It used to ask + Vault; reversed 2026-08-03 when the server economy printed "27.00 Dollars" + over our whole coins.) The locale never carries a `$` of its own — the symbol + arrives inside the formatted value. ## Environment (verified — do not "upgrade" blindly) diff --git a/TESTING.md b/TESTING.md index 408255d..1d46133 100644 --- a/TESTING.md +++ b/TESTING.md @@ -104,6 +104,9 @@ noise. a bamboo raft 2, a pale oak chest boat 21. If any reads a multiple of ten, `config.yml` has drifted from the code defaults - `ConfigAgreementTest` should have caught it. +- [ ] **Money reads "$1,728" everywhere.** No price anywhere shows cents, a + doubled symbol, or a currency name - check the market pages, the status + line, fines, and the shipwright. - [ ] **The sell page tells identical goods apart.** With one enchanted and two plain iron swords aboard, the sell list shows two rows: "Iron Sword ✦ x1" and "Iron Sword x2", the enchanted one priced higher. No row shows a raw diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index e1247e0..6417168 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,23 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Money formats itself (2026-08-03) + +"Coal x1 - $27.00 Dollars": the server economy's `format()` put the cents back +and appended a currency name, and the locale added a `$` of its own. Delegating +to Vault for display - my earlier call - loses to reality: TradeWinds rounds +every price to a whole coin precisely so dense dialogs read clean, then handed +the number to a formatter that undid it. + +`Money.format` now renders "$1,728" itself: whole coins, US-style thousands +separators, symbol from **`economy.currency-symbol`** (so a gems-or-credits +server is not stuck with `$`). All 23 hand-written `$` were stripped from the +locale - the symbol lives in exactly one place. Verified every money variable in +every locale string is produced by `Money.format` before deciding this was safe. +Vault still handles the actual accounts; only display changed. `MoneyTest` pins +the format, including that a stray fraction rounds away rather than resurfacing +as cents. + ## Playtest fixes — config drift and telling goods apart (2026-08-03) **An oak boat carried 30 slots.** The shipped `config.yml` had every value in diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 0c90706..7fd432e 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -677,6 +677,12 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "economy.resale-ship-radius") private int resaleShipRadius = 8000; + @ConfigComment("The currency symbol shown before every price - \"$27\", \"$1,728\". Prices are") + @ConfigComment("whole coins by design, so TradeWinds formats money itself rather than asking") + @ConfigComment("the economy plugin, whose formatter may add cents and a currency name.") + @ConfigEntry(path = "economy.currency-symbol") + private String currencySymbol = "$"; + @ConfigComment("Harbour reports: what a broker charges PER PORT to fill in your logbook, and") @ConfigComment("how far the report reaches per tech level of the island selling it. This gives") @ConfigComment("a developed port a role beyond its shelves, and a reason to call somewhere you") @@ -3184,6 +3190,8 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setResaleMarkup(double resaleMarkup) { this.resaleMarkup = resaleMarkup; } public int getResaleShipRadius() { return resaleShipRadius; } public void setResaleShipRadius(int resaleShipRadius) { this.resaleShipRadius = resaleShipRadius; } + public String getCurrencySymbol() { return currencySymbol == null ? "$" : currencySymbol; } + public void setCurrencySymbol(String currencySymbol) { this.currencySymbol = currencySymbol; } public double getMarketReportPricePerIsland() { return marketReportPricePerIsland; } public void setMarketReportPricePerIsland(double v) { this.marketReportPricePerIsland = v; } public double getMarketReportRadiusPerTechLevel() { return marketReportRadiusPerTechLevel; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/Money.java b/src/main/java/world/bentobox/tradewinds/economy/Money.java index 79cadb6..635ffac 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/Money.java +++ b/src/main/java/world/bentobox/tradewinds/economy/Money.java @@ -3,17 +3,19 @@ import world.bentobox.tradewinds.TradeWinds; /** - * Renders an amount of money the way THIS server's economy renders it. + * Renders an amount of money the TradeWinds way: whole coins, thousands + * separators, one currency symbol - "$27", "$1,728". *

- * Vault's {@code Economy.format(double)} knows the server's currency name and - * how many decimal places it keeps ({@code fractionalDigits()}); an admin who - * runs a whole-coin economy has said so, and printing "$0.97" over the top of - * that ignores them. Every price in TradeWinds used to be hand-formatted to - * two decimals, which is where the ragged market charts came from. + * This deliberately does NOT delegate to Vault's {@code Economy.format} any + * more (reversed 2026-08-03). That was tried, and the server's economy plugin + * rendered "27.00 Dollars": TradeWinds rounds every price to a whole coin + * precisely so the charts read clean, and handing the number to a formatter + * that puts the cents back - plus a currency name after a locale that already + * carries the symbol - defeated both halves at once. Dialogs are dense with + * prices; they need the compact form. *

- * Without Vault there is nothing to ask, so the fallback prints whole units - - * which is what TradeWinds' own prices now are (see - * {@link PriceModel#playerBuysAt}). + * The symbol is a config knob ({@code economy.currency-symbol}) so an admin + * whose economy is gems or credits is not stuck with a dollar sign. * * @author tastybento */ @@ -26,10 +28,10 @@ private Money() { /** * @param addon the addon * @param amount an amount of money - * @return the amount as this server's economy would write it + * @return the amount as whole coins with the configured symbol: "$1,728" */ public static String format(TradeWinds addon, double amount) { - return addon.getPlugin().getVault().map(vault -> vault.format(amount)) - .orElseGet(() -> String.format("%.0f", amount)); + return addon.getSettings().getCurrencySymbol() + + String.format(java.util.Locale.US, "%,d", Math.round(amount)); } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7b0b9e7..aa26810 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -306,6 +306,10 @@ economy: resale-markup: 1.6 # How far a trader ships a consignment to reach another port's shelf. resale-ship-radius: 8000 + # The currency symbol shown before every price - "$27", "$1,728". Prices are + # whole coins by design, so TradeWinds formats money itself rather than asking + # the economy plugin, whose formatter may add cents and a currency name. + currency-symbol: "$" # Harbour reports: what a broker charges PER PORT to fill in your logbook, and how # far the report reaches per tech level of the island selling it. Gives a developed # port a role beyond its shelves. 0 for either disables reports. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 2dc16b3..9d37a16 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -156,7 +156,7 @@ tradewinds: market: title: "[name] Market" subtitle: "[type] (Tech [tech]), [band]" - status: "Balance: $[balance] | Hold space: ~[space] items" + status: "Balance: [balance] | Hold space: ~[space] items" sell: "Sell cargo" sell-tooltip: "Sell your hold's goods here" buy: "Buy goods" @@ -173,11 +173,11 @@ tradewinds: shelf-tooltip: "Goods other sailors sold on, shipped here by their traders." shelf-title: "Secondhand at [name]" shelf-body: "Odds and ends the trader took in. Sold as seen." - shelf-item-line: "[material] - $[price]" - shelf-buy: "[material] - $[price]" + shelf-item-line: "[material] - [price]" + shelf-buy: "[material] - [price]" shelf-buy-tooltip: "[details]" report: "Harbour report" - report-tooltip: "The broker will fill in your logbook for [number] nearby ports. $[price]" + report-tooltip: "The broker will fill in your logbook for [number] nearby ports. [price]" shipwright: "Shipwright" shipwright-tooltip: "Bigger boats - an upgrade keeps your cargo and grows the hold" back: "< Back" @@ -193,26 +193,26 @@ tradewinds: shipwright-title: "[name] - Shipwright" shipwright-body: "Upgrades only: your old boat is taken in and broken up, your cargo stays aboard. Smart sailors craft rare-wood boats instead of paying." sell-pick: "[material] x[amount]" - sell-pick-tooltip: "$[price] each. This port will take about [depth] more at that price." - sell-qty-one: "Sell 1 - $[price]" - sell-qty-batch: "Sell [amount] - $[price]" - sell-qty-all: "Sell all [amount] - $[price]" + sell-pick-tooltip: "[price] each. This port will take about [depth] more at that price." + sell-qty-one: "Sell 1 - [price]" + sell-qty-batch: "Sell [amount] - [price]" + sell-qty-all: "Sell all [amount] - [price]" sell-qty-tooltip: "The price falls as this port fills up - watch it move." sell-item-depth: "This port will take about [depth] more at this price." item-enchanted: "[material] ✦" item-worn: "[material] (worn)" - sell-item-line: "[material] x[amount] - $[price] each" - buy-one: "[material] x1 - $[price]" - buy-batch: "x[amount] - $[price]" - buy-tooltip-each: "$[price] each; limited by balance and hold space" - outfit-one: "[material] x1 - $[price]" - outfit-batch: "x[amount] - $[price]" - outfit-tooltip: "$[price] each - carried in your pack, not the hold" - hull: "[material] - $[price]" + sell-item-line: "[material] x[amount] - [price] each" + buy-one: "[material] x1 - [price]" + buy-batch: "x[amount] - [price]" + buy-tooltip-each: "[price] each; limited by balance and hold space" + outfit-one: "[material] x1 - [price]" + outfit-batch: "x[amount] - [price]" + outfit-tooltip: "[price] each - carried in your pack, not the hold" + hull: "[material] - [price]" hull-tooltip: "[slots] cargo slots. Replaces your current boat; contents stay put." - pouch: "Trading Pouch - $[price]" + pouch: "Trading Pouch - [price]" pouch-tooltip: "Holds 64 items ([owned]/[cap] carried). Cargo expanders carry 27 stacks each." - expander: "Cargo Expander - $[price]" + expander: "Cargo Expander - [price]" expander-tooltip: "+20 cargo slots, installed into a Pale Oak Chest Boat ([owned] installed). Price doubles each time." no-hulls: "Nothing on the slipway beats your boat" no-hulls-tooltip: "This port cannot build bigger than you sail. Seek higher tech." @@ -312,7 +312,7 @@ tradewinds: trade: not-at-market: "There is no market here - trade within an island's protection range." nothing-of-that: "Nothing of that in your hold." - boat-bought: "[material] purchased for $[price] - [slots] cargo slots. Your old boat was broken up; your cargo is aboard." + boat-bought: "[material] purchased for [price] - [slots] cargo slots. Your old boat was broken up; your cargo is aboard." boat-not-here: "Your ship is not at this port - bring it into the island's waters to trade." refit-needs-ship: "The yard cannot refit a ship that is not here - bring it into the island's waters." boat-not-sold-here: "This yard cannot sell you that boat." @@ -327,15 +327,15 @@ tradewinds: barred: "The market closes as you approach. 'We know what you are. Take it somewhere lawless.'" nothing-to-sell: "Nothing in your hold that this island will pay for." nothing-for-sale: "This island has nothing for sale - it only buys." - sold: "Sold [amount] x [material] for $[price]." - bought: "Bought [amount] x [material] for $[price]." + sold: "Sold [amount] x [material] for [price]." + bought: "Bought [amount] x [material] for [price]." cannot-afford: "You cannot afford that." no-hold-space: "No room in your hold - sell something, destroy something, or buy a bigger boat." needs-chest-boat: "Expanders are stowed in a chest boat - buy one from the shipwright first." - expander-bought: "Cargo expander installed for $[price]." + expander-bought: "Cargo expander installed for [price]." expander-not-sold-here: "Only the top-tech yards (Tech 7) fit expanders." expander-needs-flagship: "Expanders install only into a Pale Oak Chest Boat with a free cargo slot." - pouch-bought: "Trading pouch purchased for $[price]." + pouch-bought: "Trading pouch purchased for [price]." pouch-cap: "You cannot carry any more trading pouches." charity-given: "The harbourmaster waves at a bamboo raft. 'Get back to work, sailor.'" charity-not-needed: "The harbourmaster looks you over. 'You'll manage.'" diff --git a/src/test/java/world/bentobox/tradewinds/economy/MoneyTest.java b/src/test/java/world/bentobox/tradewinds/economy/MoneyTest.java new file mode 100644 index 0000000..833c6bb --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/economy/MoneyTest.java @@ -0,0 +1,61 @@ +package world.bentobox.tradewinds.economy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +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.TradeWinds; + +/** + * Money renders as whole coins with one symbol - "$27", "$1,728" - and never + * as "$27.00 Dollars" (playtest 2026-08-03: delegating to the server economy's + * formatter reintroduced the cents that whole-coin pricing exists to remove, + * plus a currency name on top of the symbol the locale already carried). + * + * @author tastybento + */ +class MoneyTest extends CommonTestSetup { + + private TradeWinds addon; + private Settings settings; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + } + + @Test + void testWholeCoinsWithThousandsSeparators() { + assertEquals("$27", Money.format(addon, 27.0)); + assertEquals("$432", Money.format(addon, 432.0)); + assertEquals("$1,728", Money.format(addon, 1728.0)); + assertEquals("$110,250", Money.format(addon, 110250.0)); + assertEquals("$0", Money.format(addon, 0.0)); + } + + @Test + void testStrayFractionsRoundAwayInsteadOfShowing() { + // Prices are whole by design; if a fraction ever leaks in, display must + // not resurrect the cents + assertEquals("$27", Money.format(addon, 27.0000001)); + assertEquals("$28", Money.format(addon, 27.5)); + } + + @Test + void testTheSymbolIsConfigurable() { + settings.setCurrencySymbol("¤"); + assertEquals("¤1,728", Money.format(addon, 1728.0)); + // A null symbol falls back rather than printing "null27" + settings.setCurrencySymbol(null); + assertEquals("$27", Money.format(addon, 27.0)); + } +} From 4fdacab5bee7a673dc975818f62bec56d8e4a9d4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 21:29:02 -0700 Subject: [PATCH 090/112] Move fuel with the mouse, and let bought coal reach the tank Two faults behind one screenshot. The real bug: bought coal carries the purchase mark, the mark makes it fail isSimilar against a plain stack, and moveCargoToFuel matched with new ItemStack(material) - so the transfer moved nothing, silently, and the player got the one-way message from the withdraw path instead. Fixed with a stack-matching overload; the mark evaporates in the tank, because fuel is material-keyed and explicitly exempt from the one-way rule. Regression test drives the real HoldManager with a marked mock. The gesture work: left-click picks a stack up onto the cursor, and dropping it does what dropping means - on the fuel row it fuels (right-click feeds one at a time, like a furnace), in your own inventory it goes ashore (bought cargo refused with the reason), anywhere else in the window it goes back. Shift-click keeps the quick route and right-click still selects for the TNT. What makes this safe rather than hairy: the pickup is cosmetic. Nothing leaves the database until the drop lands, so closing with a full cursor loses nothing. The three routes by which the cursor copy could have become a real item are each closed - window clicks were already cancelled, drags are refused entirely while cargo rides the cursor, and on close the cursor is cleared before the server hands the copy back. Render hides the held amount so a picked-up stack never shows twice. 302 tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 11 +- docs/PROGRESS.md | 25 +++ .../bentobox/tradewinds/travel/HoldGui.java | 172 +++++++++++++++++- .../tradewinds/travel/HoldService.java | 21 ++- src/main/resources/locales/en-US.yml | 3 +- .../tradewinds/travel/HoldServiceTest.java | 27 +++ 6 files changed, 242 insertions(+), 17 deletions(-) diff --git a/TESTING.md b/TESTING.md index 1d46133..2982cf4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -98,8 +98,15 @@ noise. sale or by the TNT. Selling it still works. - [ ] **Bought and mined goods do not merge.** Buy 5 diamonds and mine 20: they occupy separate slots, and only the mined 20 can be withdrawn. -- [ ] Hold gestures: left-click withdraws, shift-click sends coal to the fuel - row, right-click selects for the TNT. +- [ ] **Fuel moves by mouse.** Pick up coal in the hold window (left-click), + drop it on the fuel row: it fuels. Right-click on the fuel row feeds one + lump at a time. Works with coal BOUGHT at a market, not just mined coal. +- [ ] **The cursor cannot dupe.** Pick up a stack and press Escape: the window + closes, nothing lands in your inventory, and the cargo is still in the + hold. Try dragging the held stack across your own inventory: refused. +- [ ] Hold gestures: left-click picks up, drop on fuel row fuels, drop in your + inventory takes ashore (bought cargo refused), drop elsewhere puts back; + shift-click is the quick route; right-click selects for the TNT. - [ ] **Boat capacity matches the ladder.** An oak boat's hold says **3 slots**, a bamboo raft 2, a pale oak chest boat 21. If any reads a multiple of ten, `config.yml` has drifted from the code defaults - `ConfigAgreementTest` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 6417168..90dc4b4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,31 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Fuel by mouse, and the marked-coal bug (2026-08-03) + +**Bought coal could not be fuelled.** The purchase mark makes a bought stack +fail `isSimilar` against a plain one, and `moveCargoToFuel` matched with +`new ItemStack(material)` - so the transfer silently moved nothing, and the +withdraw path answered with the one-way message instead. Fixed with a stack- +matching overload; the mark evaporates in the tank (fuel is material-keyed and +explicitly exempt from the one-way rule). Regression-tested. + +**The hold window now moves cargo with ordinary mouse gestures.** Left-click +picks a stack up onto the cursor; drop it on the fuel row to fuel it (right- +click feeds one at a time, like a furnace), drop it in your own inventory to +take it ashore (refused for trader-bought cargo), drop it anywhere else in the +window to put it back. Shift-click keeps the quick route (fuel to the tank, +anything else ashore); right-click still selects for the TNT. + +**The safety property that makes this simple:** the pickup is *cosmetic*. +Nothing leaves the database until the drop lands somewhere meaningful, so a +window closed - or a session dropped - with a full cursor loses nothing. The +three places the cursor copy could have become real are each guarded: every +click in the window is already cancelled; drags are refused entirely while the +cursor carries cargo (a drag over the player's own slots would deposit the +copy); and on close the cursor is cleared before the server can hand the copy +to the player. Render hides the held amount so the stack never shows twice. + ## Money formats itself (2026-08-03) "Coal x1 - $27.00 Dollars": the server economy's `format()` put the cents back diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java index 6d8a3fa..96efd50 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldGui.java @@ -85,11 +85,20 @@ private record Selection(ItemStack item, int amount, int expanderIndex) { private record Nested(Inventory inventory, int index) { } + /** + * A cargo stack "on the cursor". The pickup is cosmetic - nothing leaves + * the database until the stack is dropped somewhere that means something - + * so closing the window with a full cursor loses nothing. + */ + private record Held(ItemStack item, int amount) { + } + private final TradeWinds addon; private final Map open = new HashMap<>(); private final Map selected = new HashMap<>(); private final Map nested = new HashMap<>(); private final Map nestedSelected = new HashMap<>(); + private final Map held = new HashMap<>(); public HoldGui(TradeWinds addon) { this.addon = addon; @@ -130,8 +139,9 @@ private void render(Player player, Inventory inv) { inv.setItem(slot, border); } inv.setItem(TNT_SLOT, tnt(user)); - // Cargo: consolidated stacks, then installed expanders, then space - List stacks = hold.cargo(id); + // Cargo: consolidated stacks, then installed expanders, then space. + // A stack riding the cursor is hidden here, or it would show twice. + List stacks = displayCargo(id); int expanders = hold.expanderCount(id); int capacity = hold.capacitySlots(player); for (int i = 0; i < CARGO.length; i++) { @@ -190,6 +200,29 @@ private ItemStack expanderItem(User user, UUID id, int index) { return item; } + /** + * The cargo stacks as the window should draw them: the database's, minus + * whatever is currently riding the cursor. Copies - the database's own + * stacks must never be mutated for display. + */ + private List displayCargo(UUID id) { + Held holding = held.get(id); + List out = new ArrayList<>(); + int hide = holding == null ? 0 : holding.amount(); + for (ItemStack stack : addon.getHoldService().cargo(id)) { + int amount = stack.getAmount(); + if (hide > 0 && CargoStore.stacksTogether(stack, holding.item())) { + int hidden = Math.min(hide, amount); + amount -= hidden; + hide -= hidden; + } + if (amount > 0) { + out.add(CargoStore.copyOf(stack, amount)); + } + } + return out; + } + /** * A FUEL map as max-size stacks, display order preserved. Cargo no longer * needs this - it is already stored one stack per slot. @@ -294,7 +327,10 @@ public void onClick(InventoryClickEvent event) { if (top.equals(open.get(id))) { event.setCancelled(true); Inventory clicked = event.getClickedInventory(); - if (clicked != null && clicked != top) { + Held holding = held.get(id); + if (holding != null) { + heldClick(player, clicked, top, event.getSlot(), event.getClick(), holding); + } else if (clicked != null && clicked != top) { deposit(player, event.getCurrentItem()); } else if (clicked == top) { topClick(player, event.getSlot(), event.getCurrentItem(), event.getClick()); @@ -455,19 +491,125 @@ private void topClick(Player player, int slot, ItemStack shown, ClickType click) Bukkit.getScheduler().runTask(addon.getPlugin(), () -> openNested(player, expander)); return; } - // Right-click selects for the TNT; shift-click sends burnable cargo to - // the fuel row; a plain left-click takes the goods back out + // Right-click selects for the TNT; shift-click is the quick path (fuel + // to the tank, anything else ashore); a plain left-click picks the + // stack UP, to be dropped where it should go - the fuel row, back into + // the hold, or your inventory - with ordinary mouse movement. if (click == ClickType.RIGHT) { selected.put(id, new Selection(shown, shown.getAmount(), -1)); user.sendMessage("tradewinds.hold.selected", "[amount]", String.valueOf(shown.getAmount()), "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(shown.getType().name())); return; } - if (click.isShiftClick() && addon.getFuelService().fuelValue(shown.getType()) > 0) { - moveCargoFuel(player, shown); + if (click.isShiftClick()) { + if (addon.getFuelService().fuelValue(shown.getType()) > 0) { + moveCargoFuel(player, shown); + } else { + withdrawCargo(player, shown); + } + return; + } + held.put(id, new Held(shown, shown.getAmount())); + cursor(player, CargoStore.copyOf(shown, shown.getAmount())); + } + + /** + * A click while a cargo stack rides the cursor. Vanilla semantics as far as + * they translate: drop on the fuel row to fuel it (right-click feeds one at + * a time), drop in your own inventory to take it ashore (refused for + * trader-bought cargo), anywhere else in the window puts it back. + */ + private void heldClick(Player player, Inventory clicked, Inventory top, int slot, ClickType click, + Held holding) { + UUID id = player.getUniqueId(); + User user = User.getInstance(player); + if (clicked == top) { + if (isFuelSlot(slot)) { + placeFuel(player, holding, click == ClickType.RIGHT ? 1 : holding.amount()); + return; + } + putBack(player); + return; + } + if (clicked == null) { + // Clicked outside the window entirely: put it back, never overboard + putBack(player); + return; + } + // Dropped in the player's own inventory: take it ashore + int taken = addon.getHoldService().withdraw(player, holding.item(), holding.amount()); + if (taken < 0) { + user.sendMessage("tradewinds.hold.bought-cargo-locked"); + thud(player); + putBack(player); return; } - withdrawCargo(player, shown); + if (taken == 0) { + user.sendMessage("tradewinds.hold.withdraw-failed"); + thud(player); + putBack(player); + return; + } + user.sendMessage("tradewinds.hold.withdrawn", "[amount]", String.valueOf(taken), "[material]", + world.bentobox.tradewinds.economy.PriceEngine.prettify(holding.item().getType().name())); + chime(player); + clearHeld(player); + } + + /** + * Feed the tank from the cursor. Whatever does not fit stays on the cursor, + * exactly as a furnace refuses what its fuel slot cannot take. + */ + private void placeFuel(Player player, Held holding, int amount) { + UUID id = player.getUniqueId(); + User user = User.getInstance(player); + if (addon.getFuelService().fuelValue(holding.item().getType()) <= 0) { + user.sendMessage("tradewinds.hold.fuel-only"); + thud(player); + return; + } + int moved = addon.getHoldService().moveCargoToFuel(player, holding.item(), + Math.min(amount, holding.amount())); + if (moved <= 0) { + user.sendMessage("tradewinds.hold.fuel-full"); + thud(player); + return; + } + chime(player); + int left = holding.amount() - moved; + if (left <= 0) { + clearHeld(player); + } else { + held.put(id, new Held(holding.item(), left)); + cursor(player, CargoStore.copyOf(holding.item(), left)); + } + } + + private void putBack(Player player) { + // The pickup never touched the database, so putting back is forgetting + clearHeld(player); + } + + private void clearHeld(Player player) { + held.remove(player.getUniqueId()); + cursor(player, null); + } + + /** + * Show (or clear) the cursor stack. A tick later, because the server + * restates the cursor after a cancelled click and would wipe it. + */ + private void cursor(Player player, ItemStack item) { + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> player.setItemOnCursor(item)); + } + + private static boolean isFuelSlot(int slot) { + for (int fuelSlot : FUEL) { + if (fuelSlot == slot) { + return true; + } + } + return false; } /** @@ -586,8 +728,12 @@ public void onDrag(InventoryDragEvent event) { Nested nestedView = nested.get(id); boolean ours = top.equals(open.get(id)) || (nestedView != null && top.equals(nestedView.inventory())); - // Any drag touching a hold window is refused - deposits are clicks - if (ours && event.getRawSlots().stream().anyMatch(raw -> raw < top.getSize())) { + // Any drag touching a hold window is refused - deposits are clicks. + // While cargo rides the cursor, ALL drags are refused: a drag over the + // player's own slots would deposit the cursor copy for real, which is + // duplication. + if (ours && (held.containsKey(id) + || event.getRawSlots().stream().anyMatch(raw -> raw < top.getSize()))) { event.setCancelled(true); } } @@ -605,6 +751,12 @@ public void onClose(InventoryCloseEvent event) { if (closing.equals(open.get(id))) { open.remove(id); selected.remove(id); + // A stack still on the cursor was never taken out of the database, + // so clearing the cursor loses nothing - but NOT clearing it would + // let the server hand the copy to the player, which duplicates it + if (held.remove(id) != null && event.getPlayer() instanceof Player p) { + p.setItemOnCursor(null); + } // The carried boat item's lore shows cargo/fuel: keep it true if (event.getPlayer() instanceof Player player) { addon.getHoldService().active(id) diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index b877652..d448f9f 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -314,7 +314,20 @@ public int withdraw(Player player, ItemStack like, int amount) { * @return how many moved */ public int moveCargoToFuel(Player player, Material material, int amount) { - if (addon.getFuelService().fuelValue(material) <= 0 || amount <= 0) { + return moveCargoToFuel(player, new ItemStack(material), amount); + } + + /** + * As {@link #moveCargoToFuel(Player, Material, int)} but matching the exact + * stack. This matters for trader-bought fuel: the purchase mark makes a + * bought coal fail {@code isSimilar} against a plain one, so matching by + * bare material silently moved nothing - which is how "I bought coal for + * fuel and cannot fuel with it" happened (playtest 2026-08-03). The mark + * evaporates in the tank: fuel is material-keyed because fuel is fungible, + * and fuel is explicitly exempt from the one-way rule. + */ + public int moveCargoToFuel(Player player, ItemStack like, int amount) { + if (like == null || addon.getFuelService().fuelValue(like.getType()) <= 0 || amount <= 0) { return 0; } UUID playerId = player.getUniqueId(); @@ -322,15 +335,15 @@ public int moveCargoToFuel(Player player, Material material, int amount) { if (hold.isEmpty()) { return 0; } - int fit = Math.min(amount, fuelCapacityFor(playerId, material)); + int fit = Math.min(amount, fuelCapacityFor(playerId, like.getType())); if (fit <= 0) { return 0; } - int taken = remove(player, new ItemStack(material), fit); + int taken = remove(player, like, fit); if (taken <= 0) { return 0; } - hold.get().getFuel().merge(material.name(), taken, Integer::sum); + hold.get().getFuel().merge(like.getType().name(), taken, Integer::sum); addon.getHoldManager().save(hold.get()); return taken; } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 9d37a16..dd10a2b 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -288,8 +288,9 @@ tradewinds: locked: "Locked" locked-lore: "A bigger boat unlocks this slot." fuel-slot: "Fuel slot" + fuel-only: "Only fuel goes in the tank." fuel-slot-lore: "Wood, coal, lava. Fuel moves freely in and out." - border-lore: "Click to take cargo out. Shift-click burnables into the fuel row. Right-click to select for the TNT." + border-lore: "Pick up a stack and drop it: on the fuel row to fuel it, in your inventory to take it ashore, anywhere here to put it back. Shift-click for the quick route. Right-click selects for the TNT." withdrawn: "Taken from the hold: [amount] x [material]" withdraw-failed: "No room in your inventory for that." bought-cargo-locked: "That cargo was bought for trade - it leaves the hold by sale or by the TNT, not back ashore." diff --git a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java index 9d41c05..2d0154d 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/HoldServiceTest.java @@ -127,6 +127,33 @@ private ItemStack markedStack(Material material) { return item; } + @Test + void testBoughtFuelStillFuels() { + // "I bought coal for fuel, but I cannot take it out of the hold" + // (playtest 2026-08-03): the purchase mark made bought coal fail + // isSimilar against the plain stack the fuel path matched with, so the + // transfer silently moved nothing. Fuel is exempt from the one-way rule. + holds.giveBoat(uuid, Material.OAK_BOAT); + ItemStack boughtCoal = markedStack(Material.COAL); + assertEquals(16, service.add(mockPlayer, boughtCoal, 16)); + assertEquals(16, service.moveCargoToFuel(mockPlayer, boughtCoal, 16), + "Bought coal must reach the tank"); + assertEquals(0, service.count(mockPlayer, boughtCoal), "and leave the cargo slots"); + // The tank is material-keyed - the mark evaporates - so it comes back + // out freely, like any fuel + assertEquals(16, service.removeFuel(uuid, Material.COAL, 16)); + } + + @Test + void testPartialFuelTransferLeavesTheRestAboard() { + holds.giveBoat(uuid, Material.OAK_BOAT); + ItemStack coal = markedStack(Material.COAL); + assertEquals(16, service.add(mockPlayer, coal, 16)); + // One lump at a time - the right-click gesture + assertEquals(1, service.moveCargoToFuel(mockPlayer, coal, 1)); + assertEquals(15, service.count(mockPlayer, coal)); + } + @Test void testNoBoatNoHold() { holds.clearBoat(uuid); From 3fee4829e8c75940a6aeb5c04696567c6f7ab0e4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 21:47:17 -0700 Subject: [PATCH 091/112] Declare the permissions /tw prices and priceaudit shipped without Phase 6 added two commands and never declared their permissions in addon.yml, so they had no default and permission plugins denied them - on exactly the command the harbour report's purchase message tells the player to run. This is the third member of the artifact-drift family, after the config maps that BentoBox replaces rather than merges and the locale keys that render as themselves when missing. Same shape every time: two artifacts must agree, and disagreement breaks the game instead of the build. Same cure, too: PermissionDeclarationTest scans every setPermission() call in the source and asserts the prefixed node is declared in addon.yml with a default and a description. Verified by re-removing the prices declaration and watching the test name it. One trap inside the guard itself, recorded in PROGRESS: permission names contain dots, which YamlConfiguration reads as nesting, so the permissions section's top-level keys are path segments rather than permissions. A node counts as a declaration iff it carries default or description. 306 tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 3 + docs/PROGRESS.md | 20 +++++ src/main/resources/addon.yml | 9 ++ .../tradewinds/PermissionDeclarationTest.java | 82 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 src/test/java/world/bentobox/tradewinds/PermissionDeclarationTest.java diff --git a/TESTING.md b/TESTING.md index 2982cf4..4226472 100644 --- a/TESTING.md +++ b/TESTING.md @@ -82,6 +82,9 @@ noise. - [ ] **The counter says when to stop.** On the sell page, a good's tooltip states how many more the port will take at that price. Sell that many: the price drops and the depth reads near zero. +- [ ] **A fresh player can run every player command.** `/tw prices`, chart, + go, starchart, restart, fine - none says "no permission" without an admin + having turned it off. - [ ] **The logbook only knows where you have been.** `/tw prices` on a fresh player says the logbook is empty. Open a port's market, run it again: that port appears with an age. A port you have only *charted* never appears. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 90dc4b4..79695f2 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,26 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## /tw prices shipped permission-less (2026-08-03) + +Phase 6's two new commands never got their permissions declared in `addon.yml`, +so `tradewinds.island.prices` and `tradewinds.admin.priceaudit` had no default +and permission plugins denied them - "I do not have permission to execute that +command", on the command the harbour report tells you to run. + +Third member of the artifact-drift family, after the config maps BentoBox +replaces rather than merges and the locale keys that render as themselves. +Same shape every time: two artifacts must agree, disagreement breaks the game +and not the build. Same cure: **`PermissionDeclarationTest`** scans every +`setPermission()` call and asserts the prefixed permission is declared in +`addon.yml` with a default and a description. Verified by re-removing the +prices declaration and watching it name it. + +Trap inside the guard itself: permission names contain dots, which +`YamlConfiguration` reads as NESTING - `getKeys(false)` on the permissions +section returns path segments, not permissions. A node is a declaration iff it +carries `default` or `description`. + ## Fuel by mouse, and the marked-coal bug (2026-08-03) **Bought coal could not be fuelled.** The purchase mark makes a bought stack diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 0ddac94..c655766 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -39,6 +39,12 @@ permissions: tradewinds.island.restart: description: Allow restarting the trading career (config-capped) default: true + tradewinds.island.prices: + description: > + Allow the trader's logbook - what ports you have called at were paying, + and how long ago. Prices only enter it by visiting a market or buying a + harbour report, so this reads a player's own knowledge back to them. + default: true tradewinds.island.fine: description: > Allow settling a criminal record at a port. On by default - a player who @@ -68,6 +74,9 @@ permissions: tradewinds.admin.warpfail: description: Rig a player's next warp to fail, dropping them into the interstice default: op + tradewinds.admin.priceaudit: + description: Audit price coverage and write the report to price-audit.txt + default: op tradewinds.admin.reflag: description: Allow re-applying security-band flags to all islands default: op diff --git a/src/test/java/world/bentobox/tradewinds/PermissionDeclarationTest.java b/src/test/java/world/bentobox/tradewinds/PermissionDeclarationTest.java new file mode 100644 index 0000000..030ef4b --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/PermissionDeclarationTest.java @@ -0,0 +1,82 @@ +package world.bentobox.tradewinds; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +/** + * Every permission a command claims must be declared in {@code addon.yml}, or + * ordinary players silently lack it: an undeclared permission has no default, + * so permission plugins deny it and the player is told they may not run the + * command - which is how {@code /tw prices} shipped unusable (2026-08-03). + *

+ * Third member of the artifact-drift family, after the config maps that + * BentoBox replaces rather than merges and the locale keys that render as + * themselves when missing. In each case two artifacts must agree, disagreement + * breaks the game and not the build, and only a test closes the gap. + * + * @author tastybento + */ +class PermissionDeclarationTest { + + /** setPermission("island.x") / setPermission("admin.x") in command setup. */ + private static final Pattern SET_PERMISSION = Pattern.compile("setPermission\\(\"([a-z.-]+)\"\\)"); + + @Test + void testEveryCommandPermissionIsDeclared() throws IOException { + YamlConfiguration addonYml = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("addon.yml"))); + ConfigurationSection declared = addonYml.getConfigurationSection("permissions"); + assertTrue(declared != null, "addon.yml has no permissions section"); + + Set used = new TreeSet<>(); + try (Stream sources = Files.walk(Path.of("src/main/java"))) { + for (Path file : sources.filter(p -> p.toString().endsWith(".java")).toList()) { + Matcher matcher = SET_PERMISSION.matcher(Files.readString(file)); + while (matcher.find()) { + // BentoBox prefixes the addon's permission prefix + used.add("tradewinds." + matcher.group(1)); + } + } + } + assertTrue(used.size() >= 10, "Found only " + used.size() + " permissions - has the scan broken?"); + + List missing = used.stream() + .filter(permission -> !declared.isConfigurationSection(permission)).toList(); + assertTrue(missing.isEmpty(), "Command permissions not declared in addon.yml (players will be " + + "denied them):\n " + String.join("\n ", missing)); + } + + @Test + void testEveryDeclaredPermissionHasADefault() { + // A declaration without a default is the same silent denial by a longer + // road, and a missing description is an admin guessing. + // NB: permission names contain dots, which YamlConfiguration reads as + // NESTING - "tradewinds.island.chart" is a section three levels deep. + // A node is a permission declaration iff it carries default or + // description; anything else is just a path segment on the way down. + YamlConfiguration addonYml = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("addon.yml"))); + ConfigurationSection declared = addonYml.getConfigurationSection("permissions"); + List broken = declared.getKeys(true).stream() + .filter(declared::isConfigurationSection) + .filter(key -> declared.contains(key + ".default") || declared.contains(key + ".description")) + .filter(key -> !declared.contains(key + ".default") || !declared.contains(key + ".description")) + .toList(); + assertTrue(broken.isEmpty(), + "Permissions missing a default or description:\n " + String.join("\n ", broken)); + } +} From 0e8e894b47fcd19260fabcfaeb4c9ca69688c4e5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 21:54:59 -0700 Subject: [PATCH 092/112] Park the price logbook behind a flag, off by default Ben's call after playtest: not required for MVP. economy.price-logbook-enabled (default false) now gates all four surfaces - the /tw prices command is not registered, market visits do not write the logbook, the broker button does not appear, and buyMarketReport refuses as a backstop. Depth-at-the-counter stays; it works and is unrelated to the logbook. The playtest evidence is written down where the revisit will find it, in the salvage plan's status note: every port read "best: Gems $850" because the unfiltered view compares absolute prices and diamonds carry the highest base price everywhere, so the priciest good wins at every port - best must be priced relative to book. The category argument has no tab-complete and nothing says what the categories are. And a $500 report purchase produces one chat line - no reach shown before paying, no list of covered ports after. One Java footnote: inside the anonymous DefaultPlayerCommand, getSettings() resolves to the command's own method, so the flag check needs TradeWinds.this.getSettings(). 306 tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 3 +++ docs/PROGRESS.md | 15 +++++++++++++++ .../java/world/bentobox/tradewinds/Settings.java | 11 +++++++++++ .../world/bentobox/tradewinds/TradeWinds.java | 5 ++++- .../tradewinds/economy/MarketService.java | 2 +- .../bentobox/tradewinds/economy/TradeDialog.java | 7 +++++-- src/main/resources/config.yml | 5 +++++ tradewinds-dev-plan.md | 4 ++++ tradewinds-salvage-plan.md | 14 ++++++++++++++ 9 files changed, 62 insertions(+), 4 deletions(-) diff --git a/TESTING.md b/TESTING.md index 4226472..60d9055 100644 --- a/TESTING.md +++ b/TESTING.md @@ -85,6 +85,9 @@ noise. - [ ] **A fresh player can run every player command.** `/tw prices`, chart, go, starchart, restart, fine - none says "no permission" without an admin having turned it off. +- [ ] **The logbook is OFF by default.** `/tw prices` is not a command, and no + "Harbour report" button appears at any market. (The checks below apply + only with `economy.price-logbook-enabled: true`.) - [ ] **The logbook only knows where you have been.** `/tw prices` on a fresh player says the logbook is empty. Open a port's market, run it again: that port appears with an age. A port you have only *charted* never appears. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 79695f2..4222fe5 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,21 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The logbook is parked behind a flag (2026-08-03) + +Ben's call after playtest: the price logbook and harbour reports are not +required for MVP. Now behind **`economy.price-logbook-enabled`**, default +false: the command is not registered, market visits do not write the logbook, +the broker button does not appear, and `buyMarketReport` refuses. Four +surfaces, all gated on the one flag. Depth-at-the-counter is untouched - it +works and stays. + +What the playtest showed, for the revisit (full list in the salvage plan's +status note): every port read "best: Gems $850" because the unfiltered view +compares ABSOLUTE prices and diamonds carry the highest base price everywhere - +best needs to be relative to book; the category argument has no tab-complete; +and a $500 report purchase produces one chat line. + ## /tw prices shipped permission-less (2026-08-03) Phase 6's two new commands never got their permissions declared in `addon.yml`, diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 7fd432e..476ebdd 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -683,6 +683,15 @@ private static Map defaultBoatRanks() { @ConfigEntry(path = "economy.currency-symbol") private String currencySymbol = "$"; + @ConfigComment("FEATURE FLAG, off by default: the price logbook and harbour reports.") + @ConfigComment("Ruled not required for MVP (2026-08-03) and parked until the rough edges are") + @ConfigComment("fixed: the 'best' column compares absolute prices, so the priciest good wins") + @ConfigComment("everywhere (Gems at every port); the category argument has no tab-complete;") + @ConfigComment("and buying a report gives almost no feedback. When true: /tw prices exists,") + @ConfigComment("market visits write the logbook, and brokers sell harbour reports.") + @ConfigEntry(path = "economy.price-logbook-enabled") + private boolean priceLogbookEnabled = false; + @ConfigComment("Harbour reports: what a broker charges PER PORT to fill in your logbook, and") @ConfigComment("how far the report reaches per tech level of the island selling it. This gives") @ConfigComment("a developed port a role beyond its shelves, and a reason to call somewhere you") @@ -3192,6 +3201,8 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setResaleShipRadius(int resaleShipRadius) { this.resaleShipRadius = resaleShipRadius; } public String getCurrencySymbol() { return currencySymbol == null ? "$" : currencySymbol; } public void setCurrencySymbol(String currencySymbol) { this.currencySymbol = currencySymbol; } + public boolean isPriceLogbookEnabled() { return priceLogbookEnabled; } + public void setPriceLogbookEnabled(boolean priceLogbookEnabled) { this.priceLogbookEnabled = priceLogbookEnabled; } public double getMarketReportPricePerIsland() { return marketReportPricePerIsland; } public void setMarketReportPricePerIsland(double v) { this.marketReportPricePerIsland = v; } public double getMarketReportRadiusPerTechLevel() { return marketReportRadiusPerTechLevel; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 0fffe85..afc9997 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -190,7 +190,10 @@ public void setup() { // hop through another game mode is a free ride to a market. new TWWarpCommand(this); new TWChartCommand(this); - new TWPricesCommand(this); + if (TradeWinds.this.getSettings().isPriceLogbookEnabled()) { + // Behind a flag, off by default: parked pre-MVP (2026-08-03) + new TWPricesCommand(this); + } new TWStarChartCommand(this); new TWTradeCommand(this); new TWRestartCommand(this); diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index b68de88..6163848 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -308,7 +308,7 @@ public java.util.Map currentPrices(IslandSpec spec) { public int buyMarketReport(Player player, IslandSpec spec) { User user = User.getInstance(player); Optional vault = addon.getPlugin().getVault(); - if (vault.isEmpty() || addon.getOverWorld() == null) { + if (!addon.getSettings().isPriceLogbookEnabled() || vault.isEmpty() || addon.getOverWorld() == null) { return 0; } List ports = reportablePorts(spec); diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 60396cc..9951fc7 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -65,7 +65,9 @@ public void openMain(Player player, IslandSpec spec) { boolean boatHere = addon.getMarketService().boatIsHere(player, spec); // Standing at the counter is how a trader learns a port's prices - and // the only way, because the market is learned, not published - recordPrices(player, spec); + if (addon.getSettings().isPriceLogbookEnabled()) { + recordPrices(player, spec); + } // Too poor in fuel to warp anywhere from here? Then the most useful // thing this screen can do is point at where the fuel is sold. An // action bar fades; a button that says "buy fuel here" does not. @@ -97,7 +99,8 @@ public void openMain(Player player, IslandSpec spec) { } // The broker: pay to have your logbook filled in for the ports within // this one's reach. No boat needed - it is information, not cargo. - int reportable = addon.getMarketService().reportablePorts(spec).size(); + int reportable = addon.getSettings().isPriceLogbookEnabled() + ? addon.getMarketService().reportablePorts(spec).size() : 0; if (reportable > 0 && addon.getSettings().getMarketReportPricePerIsland() > 0) { buttons.add(button(ui(player, "market.report", NO_VARS), ui(player, "market.report-tooltip", "[number]", String.valueOf(reportable), diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index aa26810..87a5838 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -310,6 +310,11 @@ economy: # whole coins by design, so TradeWinds formats money itself rather than asking # the economy plugin, whose formatter may add cents and a currency name. currency-symbol: "$" + # FEATURE FLAG, off by default: the price logbook and harbour reports. Parked as + # not required for MVP (2026-08-03) - see tradewinds-salvage-plan.md for what + # needs fixing before it returns. When true: /tw prices exists, market visits + # write the logbook, and brokers sell harbour reports. + price-logbook-enabled: false # Harbour reports: what a broker charges PER PORT to fill in your logbook, and how # far the report reaches per tech level of the island selling it. Gives a developed # port a role beyond its shelves. 0 for either disables reports. diff --git a/tradewinds-dev-plan.md b/tradewinds-dev-plan.md index 6009c23..a55d180 100644 --- a/tradewinds-dev-plan.md +++ b/tradewinds-dev-plan.md @@ -91,6 +91,10 @@ bucket → tech gating → NBT hold → two-way hold → price discovery (depth counter, chart logbook, market reports) → resale shelves. ## Stage 8 — Post-MVP backlog (seeded, unscheduled) +- **Re-enable the price logbook + harbour reports** (`economy.price-logbook-enabled`, + built but parked 2026-08-03): fix the absolute-price "best" column, add category + tab-complete, make the report purchase visibly land. See the status note in + `tradewinds-salvage-plan.md`. - Player market stalls rentable on NPC islands (goods enter that island's trade pool; rent as money sink). - Chart data as a purchasable good; wanted-player "last seen" intel for bounty hunters. - Boat/expander insurance (cargo never insurable); boat destruction and death-drop rules tuning. diff --git a/tradewinds-salvage-plan.md b/tradewinds-salvage-plan.md index de7680f..1c10aa9 100644 --- a/tradewinds-salvage-plan.md +++ b/tradewinds-salvage-plan.md @@ -9,6 +9,20 @@ for boat identity, ownership and capture. > what each landed, the deviations, and the traps hit. Two things were > deliberately not built: plaza rumours (Phase 6 - they duplicated the logbook) > and the chart-hologram price display (the logbook surfaces via `/tw prices`). +> +> **Phase 6's logbook and harbour reports are PARKED behind +> `economy.price-logbook-enabled` (default false)** - ruled not required for MVP +> after playtest (2026-08-03). Before re-enabling, fix: +> - The unfiltered `/tw prices` "best" column compares **absolute** prices, so +> the priciest good wins everywhere - every port read "Gems $850". Best must +> be relative to book value (or the unfiltered view dropped for per-category +> only). +> - The category argument has no tab-complete, and nothing tells the player what +> the categories are. +> - Buying a report gives one chat line: no reach shown before paying, no list +> of covered ports after. A $500 purchase should visibly land. +> - Consider folding the logbook into the chart holograms instead of a command. +> Depth-at-the-counter (the sell tooltip) is NOT parked - it works and stays. ## The problem From 6f929a05570c43f6f3f9462c852f2c810c030988 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 3 Aug 2026 22:08:45 -0700 Subject: [PATCH 093/112] Record the Paper 26.2 spinning-clock quirk as upstream Clocks spin in every API-created world on 26.2 - TradeWinds and BSkyBlock alike - and tick in the main world, so it is Paper's plugin-worlds-as-custom- dimensions rework, not addon code. Written down with the evidence and the compass/bed diagnostic so nobody re-investigates it from our side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- docs/PROGRESS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4222fe5..e6dc3d7 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,23 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## Known 26.2 quirk: clocks spin in every plugin world (2026-08-03) + +Not ours. Clocks spin (Nether-style) in the TradeWinds world AND in BSkyBlock's +world on the same server; they tick correctly in the main `world`. Paper 26.2 +moved API-created worlds into real custom dimensions +(`world/dimensions/minecraft/`, via `WorldFolderMigration`), and the +client no longer treats them as "natural" for the clock dial - even though +CraftServer's createWorld still selects the vanilla overworld stem +(`natural=true`; verified in the decompiled 26.2 jar). Multiverse records, +gamerules and our createWorlds (stock AcidIsland pattern) are all clean. + +Upstream Paper issue - do not burn time re-investigating from the addon side. +Diagnostic to pin which side broke, if ever needed: a compass spinning + beds +refusing spawn in a plugin world means the dimension registered natural=false +(server bug); compass and beds fine means the clock's client logic changed +(client bug - the likely one, or bed complaints would predate us). + ## The logbook is parked behind a flag (2026-08-03) Ben's call after playtest: the price logbook and harbour reports are not From 3159dde30313574dec665e81d3f74460b8d0d0f6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 6 Aug 2026 18:07:48 -0700 Subject: [PATCH 094/112] Stage 7 complete, interstice resources, and a run of playtest fixes Stage 7a - Seafarer ranks and islet claiming: - Rank ladder from charted-island count (config thresholds, locale names), charted leaderboard with placeholders, /tw rank, /twadmin rank (stores a chartedBonus adjustment; promotion, demotion and reset all work). - /tw claim registers a wild islet as a real BentoBox island: rank gate (default Tide Captain) + price (default 150,000), protection sized to the islet, no paste, money moves only after the grid accepts. - Trading islands' range boxes shrunk to the protection range (and re-gridded on adoption): createIsland's full-distance box had reserved ~1.1km around every port, where no claim could ever fit. Stage 7b - living with a base: - Member-only warp node: the claim travels as a synthetic HomePort spec, pinned first in members' warp dialogs, warp-out at its protection border, interstice re-engage stores a "home" marker (a wild cell would re-resolve to the wrong island). - /tw go is the one "go" verb: door into the ocean from outside, step home on your own island, course-home toggle at sea (8-point by day, exact distance by night). /tw sethome + /tw home work only aboard your island. - /tw unclaim (owner-only, team emptied first, hardDeleteIsland - blocks stay), gold HOME markers on chart holograms and the Star Chart, ship's compass at FISHING outfitters (lodestone-bound, untracked). - Respawn: island members keep BentoBox's island respawn (our spawn-safety listener was stomping it); the loaner boat is granted a tick after respawn (items given during PlayerRespawnEvent are wiped). Interstice resources (tradewinds-interstice-plan.md, built same day): - IntersticeMap pure geometry: wart shoals (soul-sand domes, crown proud of the sea), wither watchtowers; strandings dodge both via a column test. - Decorator: wart crops, crimson/warped groves on grand shoals, glowstone ceiling clusters, quartz in shoal cores and brazier roots, blaze-spawner pickets, watchtowers with wither skeleton spawner + bastion-treasure chest. Netherite loot-only; End goods trade-only; entry stays by-accident. - Lighting a portal frame prints the lore refusal instead of failing silent. Boats and shops: - The yard ALWAYS sells: every hull the tech can build except the size you sail. Bigger + ship at the quay = trade-in (cargo stays); anything else is bought outright with a capture-style confirmation and the old boat left unowned where it lies. Ends the rich-but-stranded-at-spawn corner. - Moored refit fixed by identity: findPlaced() scans loaded entities for the PDC stamp instead of a position box (dismounted boats drift), used by refit, relabel and the boatIsHere purchase gate. - Outfitter batch buttons name their good ("Compass x16"), the empty slipway notice moved from a truncating button into the dialog body. World and encounters: - Deepslate below Y0 (vanilla ore variants come free with decoration). - Pirate crews actually ride their boat: abandon-ship is per encounter type in config; the old flat 30 was inside the 28-block spawn distance, so every crew ejected on its first tick. - PUFFER_SHOAL: the SAFE bands' nuisance encounter - stings, no booty (nuisance types are farm-proof by classification), capped at POLICED via the new maxBand so it never dilutes real danger. 348 tests green. Placement knobs are needsReset: fresh worlds recommended, interstice world mandatory for the new features. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 145 ++++++++- docs/PROGRESS.md | 288 +++++++++++++++++ .../world/bentobox/tradewinds/Settings.java | 153 ++++++++- .../world/bentobox/tradewinds/TradeWinds.java | 62 +++- .../tradewinds/commands/AdminRankCommand.java | 101 ++++++ .../tradewinds/commands/TWClaimCommand.java | 64 ++++ .../tradewinds/commands/TWHomeCommand.java | 54 ++++ .../tradewinds/commands/TWRankCommand.java | 64 ++++ .../tradewinds/commands/TWSetHomeCommand.java | 63 ++++ .../tradewinds/commands/TWSpawnCommand.java | 72 ++++- .../tradewinds/commands/TWUnclaimCommand.java | 65 ++++ .../tradewinds/dataobjects/TWPlayerData.java | 26 ++ .../tradewinds/economy/MarketService.java | 95 +++++- .../tradewinds/economy/TradeDialog.java | 33 +- .../tradewinds/economy/TypeEconomy.java | 8 +- .../encounters/EncounterListener.java | 12 + .../encounters/EncounterService.java | 50 ++- .../tradewinds/encounters/EncounterTable.java | 1 + .../tradewinds/encounters/EncounterType.java | 52 ++- .../tradewinds/galaxy/IntersticeMap.java | 198 ++++++++++++ .../generator/ChunkGeneratorWorld.java | 48 ++- .../generator/GalaxyIslandRegistrar.java | 23 ++ .../generator/IntersticeDecorator.java | 304 +++++++++++++++++- .../listeners/IntersticePortalListener.java | 15 +- .../listeners/SpawnRespawnListener.java | 50 ++- .../tradewinds/tasks/NavigationBarTask.java | 74 ++++- .../bentobox/tradewinds/travel/BoatRanks.java | 7 +- .../tradewinds/travel/BoatService.java | 91 ++++-- .../travel/BorderPromptListener.java | 24 +- .../tradewinds/travel/ChartHolograms.java | 34 +- .../tradewinds/travel/ChartLeaderboard.java | 123 +++++++ .../bentobox/tradewinds/travel/HomePort.java | 80 +++++ .../tradewinds/travel/IntersticeService.java | 22 +- .../tradewinds/travel/IsletClaimService.java | 144 +++++++++ .../tradewinds/travel/RankService.java | 148 +++++++++ .../tradewinds/travel/SeaArrival.java | 30 ++ .../tradewinds/travel/StarChartRenderer.java | 19 ++ .../tradewinds/travel/WarpService.java | 61 +++- src/main/resources/addon.yml | 38 ++- src/main/resources/config.yml | 84 ++++- src/main/resources/locales/en-US.yml | 93 +++++- .../commands/CompassBearingTest.java | 37 +++ .../tradewinds/economy/SaleCatalogTest.java | 16 +- .../encounters/EncounterTableTest.java | 27 +- .../tradewinds/galaxy/IntersticeMapTest.java | 115 +++++++ .../generator/ChunkGeneratorWorldTest.java | 31 +- .../generator/GalaxyIslandRegistrarTest.java | 17 + .../listeners/SpawnRespawnListenerTest.java | 22 ++ .../travel/ChartLeaderboardTest.java | 107 ++++++ .../tradewinds/travel/HomeWarpTest.java | 128 ++++++++ .../travel/IsletClaimServiceTest.java | 208 ++++++++++++ .../tradewinds/travel/RankServiceTest.java | 134 ++++++++ .../tradewinds/travel/RefitMooredTest.java | 183 +++++++++++ .../tradewinds/travel/WarpSelectionTest.java | 2 + tradewinds-dev-plan.md | 55 +++- tradewinds-interstice-plan.md | 156 +++++++++ 56 files changed, 4164 insertions(+), 192 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/AdminRankCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWClaimCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWHomeCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWRankCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWSetHomeCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/commands/TWUnclaimCommand.java create mode 100644 src/main/java/world/bentobox/tradewinds/galaxy/IntersticeMap.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/ChartLeaderboard.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/HomePort.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/IsletClaimService.java create mode 100644 src/main/java/world/bentobox/tradewinds/travel/RankService.java create mode 100644 src/test/java/world/bentobox/tradewinds/commands/CompassBearingTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/galaxy/IntersticeMapTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/ChartLeaderboardTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/HomeWarpTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/IsletClaimServiceTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/RankServiceTest.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/RefitMooredTest.java create mode 100644 tradewinds-interstice-plan.md diff --git a/TESTING.md b/TESTING.md index 60d9055..9a48070 100644 --- a/TESTING.md +++ b/TESTING.md @@ -199,8 +199,20 @@ behind it. hold opens with 2 slots. - [ ] With a boat **at the port**, buy a bigger hull: same hold, same cargo, more slots — and the hull you ride or carry visibly becomes the new type. -- [ ] With your boat **elsewhere**, the yard refuses the refit and says to - bring the ship in. + No confirmation dialog — a trade-in takes nothing from you. +- [ ] **The yard always sells** (rule change 2026-08-05): with your boat an + ocean away (die far off, respawn at spawn), the TL1 shipwright still + lists the raft and smaller hulls. Buying one shows the capture-style + confirmation, then: new boat in your pack, old boat UNOWNED where it + lies (chart still marks it, plate reads UNOWNED, another player can + take it, cargo aboard). Declining costs nothing. +- [ ] **Moored refit** (regression, 2026-08-04): exit the boat at the dock, + shove it a dozen blocks so it drifts, walk to the trader, buy a bigger + hull — the moored boat at the dock visibly becomes the new type, name + plate intact. Also works if the hull lies about as a dropped **item**. +- [ ] With your boat **elsewhere** (or moored beyond the island's protection + range), the yard refuses the refit and says to bring the ship in — and + takes no money. - [ ] Craft a bigger boat **by clicking the result** (not shift-clicking): the item has lore and right-click opens the hold. Repeat with shift-click. Crafting a smaller one is refused. @@ -255,19 +267,86 @@ behind it. --- +# Tier 3.5 — Seafarer ranks & islet claiming (~15 min, second account for two checks) + +New in Stage 7a. Claiming writes real BentoBox islands, so a bug here is a +data bug - test on a throwaway world first. + +- [ ] `/tw rank` on a fresh account: **Deck Hand**, 5 islands charted (the + starter cluster), next rung Cabin Scout at 6. +- [ ] Chart a sixth island (sail into its waters): promoted to Cabin Scout, + and you appear on the `/tw rank` top-ten board. +- [ ] `/tw claim` at sea, at a port, and ashore on a trading island: all + refuse with "no wild islet underfoot". +- [ ] On a wild islet below the rank gate: refused, message names Tide + Captain and 36 islands, no money taken. +- [ ] `/twadmin rank ` reports rank, effective and real counts; + `/twadmin rank tide-captain` promotes (check `/tw rank` and the + board agree); `... 0` demotes to Deck Hand; `... reset` returns to the + real chart. Tab-complete offers players, then rank slugs. +- [ ] Gate passed (`/twadmin rank tide-captain`) but broke: refused + with the price, no money taken. +- [ ] With rank and money: **Land ho!** - money gone exactly once, and + whatever was already built on the islet is untouched. +- [ ] Protection is islet-sized: you can build ashore and just offshore; a + second account cannot; a few boat-lengths out the second account can + build again (open ocean stays free). +- [ ] Second account standing on the claimed islet: `/tw claim` refuses with + "already claimed". The owner on a fresh islet: refused - one island + per player. +- [ ] Die without a bed: owner respawns on the claim (regression 2026-08-05: + the spawn-safety listener used to override this); an islandless + account respawns at the spawn port. Both still get the loaner raft + when their own boat is out of reach. +- [ ] `/tw sethome` on your islet sets home (respawn lands there); + `/tw home` steps to it from across the islet. Both refuse at sea, at a + port, and on someone ELSE'S island - a team member on the island can + use both, a visitor can use neither. +- [ ] `/tw go` is context-sensitive: on your island it steps you home; at + sea (with an island) it prints the bearing AND toggles the course bar; + islandless at sea it still refuses; from the lobby it still returns + you to the water you left. +- [ ] **Course bar** (7b): by day it names only the direction; wait for + night and it adds the exact distance, and the bar fills as you close. + `/tw go` again turns it off. Entering an island's waters swaps to the + island bar; making landfall on your OWN island clears the course with + a message. +- [ ] **Home warp** (7b): at any port border, the dialog's FIRST entry is + ⌂ your island, normal fuel price. A teammate sees it too; a stranger + never does. Warp home: arrive offshore facing the islet, no dialog + reopening on arrival. Row OUT across your claim's border: the dialog + offers your charted ports. With `/twadmin warpfail`, a failed home + warp drops you in the interstice and the free re-engage still says + your island's name. +- [ ] **Ship's compass** (7b): at a FISHING outfitter, a member buys a + compass - it arrives named Ship's Compass and points at their islet + from across the sea (F3 check the direction). An islandless buyer + gets a plain compass pointing at the spawn port. +- [ ] **Unclaim** (7b): refuses with a team member still aboard; owner alone + gets a confirmation, then the island record is gone - builds stay, + protection gone, and a second account can claim the same islet + (builds intact). No refund. `/tw unclaim` at the spawn island refuses + ("not a claim"). +- [ ] Claim an islet within sight of a port: either it succeeds, or it + refuses with the "port's waters" message - and refusal costs nothing. +- [ ] Restart the server: the claim survives, protection intact, and the log + shows no "distance mismatch" or duplicate-island complaints. + +--- + # Tier 4 — Economy depth (~20 min) -- [ ] Every price on every chart is a **whole coin** - no cents anywhere - +- [x] Every price on every chart is a **whole coin** - no cents anywhere - and the market's figures match your balance line's formatting (both come from the server economy's own formatter now). - [ ] Buying then selling the same good at the same island always LOSES money, including on the cheapest goods (sand, stone, kelp) where rounding is proportionally largest. -- [ ] Prices differ by island **type** (a farm sells food cheap) and by **tech** +- [x] Prices differ by island **type** (a farm sells food cheap) and by **tech** (high tech sells metals cheap, pays more for ore). -- [ ] Selling a lot of one category into one island visibly depresses its price, +- [x] Selling a lot of one category into one island visibly depresses its price, and it recovers over an hour. -- [ ] Tech shows everywhere an island is named: market, chart, warp tooltip, +- [x] Tech shows everywhere an island is named: market, chart, warp tooltip, nav bar. - [ ] Contraband (sugar) sells only at FRONTIER or rougher, at a premium. - [ ] `/tw restart` when destitute: fresh kit, chart kept, hold emptied. @@ -298,29 +377,67 @@ behind it. # Tier 6 — World, atmosphere, persistence (~20 min) -- [ ] Islet biomes suit their sea: snowy in frozen water, desert and mangrove in +- [ ] **Puffer shoal** (new 2026-08-06): loiter at sea in SAFE water + (~5-40 min at 0.5-2% per 45s roll — or bump encounters.chance.SAFE + temporarily): 2-3 pufferfish appear ahead with the "water prickles" + message. Swimming through stings and poisons; killing them drops at + most vanilla pufferfish, never booty. They never appear in FRONTIER + or rougher water. +- [ ] **Pirate crews ride their boat** (regression, 2026-08-05): in Lawless+ + water, when a PIRATE_CREW encounter spawns, the pillagers are IN the + boat and stay there while you keep your distance; they only jump out + when you close to ~10 blocks. Note whether they fire crossbows from + the deck - if they never do, raise `encounters.abandon-ship.PIRATE_CREW` + and treat the manned boat as arrival theatre. The witch still bails + out immediately (intended). +- [ ] Lighting a completed ruined-portal frame prints the "sea between + seas" refusal - no portal blocks form, in either world. +- [ ] **Interstice resources** (new 2026-08-05 — regenerate + `tradewinds_world_nether` first, placement knobs are needsReset). + Rig a warp failure (`/twadmin warpfail`) and look around the dark sea: + - [ ] Within a few hundred blocks: a soul-sand **wart shoal** with + nether wart growing on it; harvest and replant works; the rim is + wadable, not a cliff. Rarely, a shoal carries a crimson/warped + grove with shroomlights. + - [ ] Some braziers are **blaze pickets**: nether-brick crow's nest, + blazes spawning - fight from the boat, rods drop. + - [ ] **Glowstone clusters** hang from the ceiling lid, visible far + off across the water. + - [ ] **Quartz ore** in shoal cores and brazier roots, under the + waterline. + - [ ] A **wither watchtower** (they are ~1.5km apart - sail or rig + repeatedly): wither skeletons in the waterline room, doorway on + the north face, loot chest on the sealed floor above (bastion + treasure table - check it fills on first open), open top. + - [ ] A rigged failure never strands you INSIDE a shoal or tower - + always open water. + - [ ] The free re-engage still offers after any amount of lingering, + and ghast tears float where ghasts die. + - [ ] Nether wart and ghast tears occasionally drop as encounter booty + in the overworld. +- [x] Islet biomes suit their sea: snowy in frozen water, desert and mangrove in warm. Cherry grove and pale garden exist somewhere. -- [ ] Surfaces match the biome: desert sand over sandstone, badlands red sand +- [x] Surfaces match the biome: desert sand over sandstone, badlands red sand over terracotta, mangrove mud, old-growth taiga podzol, peaks bare rock. - [ ] Roughly one islet in four carries a biome-appropriate structure (igloo, fossils, ruined portal, abandoned camp). Mushroom and pale garden islets never do. -- [ ] **No jigsaw or structure blocks are visible** in any of them. A ruined +- [x] **No jigsaw or structure blocks are visible** in any of them. A ruined portal still stands on its netherrack footing; a camp tent has no glowing blocks holding it up. (Find several - the camps and portals 1/2/4/5 are the ones that carry connectors.) -- [ ] Plaza amenities scale with tech: the galley everywhere, then furnace and +- [x] Plaza amenities scale with tech: the galley everywhere, then furnace and stonecutter, smithing and grindstone, brewing, anvil, and at Tech 7 an enchanting table with bookshelves. All usable as a visitor, none breakable. -- [ ] Border curtains: blue at the edge of island space, red at the warp ring, +- [x] Border curtains: blue at the edge of island space, red at the warp ring, both passable. Colours follow `border.*`; nonsense values fall back rather than vanishing. -- [ ] A failed warp: the interstice is lit by braziers, lidded, the ghasts are +- [x] A failed warp: the interstice is lit by braziers, lidded, the ghasts are visible, and the free re-engage always works. -- [ ] Restart mid-voyage: boats, cargo, fuel, ownership, chart and island stock +- [x] Restart mid-voyage: boats, cargo, fuel, ownership, chart and island stock all survive. -- [ ] Warp arrival never lands you inside terrain or on the quay. +- [x] Warp arrival never lands you inside terrain or on the quay. --- diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index e6dc3d7..187a665 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,294 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The puffer shoal - SAFE waters get their spice (2026-08-06) + +The SAFE band's 2% encounter chance was decorative: every encounter needed +POLICED+, so the roll fired into an empty table. Ruled by Ben: SAFE needs a +nuisance - interesting, not farmable. Now: **PUFFER_SHOAL**, 2-3 pufferfish +across your course, any time of day, SAFE and POLICED bands only. + +Two new properties on `EncounterType` carry the design: +- **`nuisance`** - a hazard, not a hunter: exempt from the + every-mob-must-be-an-Enemy test (the pufferfish stings on contact without + ever being an Enemy), and worth NO booty + (`EncounterListener` checks the tag's type), so there is nothing to farm - + the only "loot" is the vanilla pufferfish, which fishing gets you faster. +- **`maxBand`** - encounters can now cap out: the shoal stays out of + FRONTIER+ where it would only dilute real danger (a uniform pick means + every filler entry steals share from pirates). + +The encounter PDC tag recording the TYPE (from the abandon-ship fix) is what +made the booty exemption a one-liner. + +## Island members respawned at spawn — our own listener stomped BentoBox (2026-08-05) + +Playtest: died owning a claimed islet, ISLAND_RESPAWN active, respawned at +the spawn port. Cause: `SpawnRespawnListener` (respawn SAFETY, written +before player islands existed, to keep bedless respawns out of the seabed) +runs at HIGH and unconditionally set the spawn plaza - AFTER BentoBox's +ISLAND_RESPAWN listener had set the island home at NORMAL. Fix: island +members now pass through untouched; the listener's job is only the +islandless. The loaner boat still applies on any respawn - the real boat is +wherever they died. Regression test pins the pass-through. + +Same-session playtest note, NOT a bug: "cannot buy a boat at Spawn" while +owning a Spruce Boat is the tech gate working - spawn is TL1 and sells +ranks 1-3 only, and the shipwright is upgrades-only by design. The recovery +path after a far-away death is the respawn loaner raft, then any TL2+ port. +Boarding the loaner (abandoning the distant boat, standard confirmation) +resets "what you sail" to the raft - and THEN the TL1 yard will happily +sell up to the oak. The rules the playtest wanted already existed; the +loaner just never arrived: + +**The loaner raft never arrived - items granted during PlayerRespawnEvent +are wiped** when the respawn restores the inventory. The grant now runs one +tick AFTER the respawn, on the player's real post-respawn position (which +also makes boat-within-reach honest for island respawners). And the +shipwright's "nothing on the slipway beats your boat" notice moved from a +truncating fake BUTTON into the dialog body, with the one offered button +pointing somewhere useful: the outfitter. + +**Rule change - the yard ALWAYS sells (ruled by Ben, 2026-08-05).** The +stranding above exposed the corner: dead at spawn, spruce boat an ocean +away, TL1 sells nothing "bigger than you sail" - a rich sailor with no way +off the island. New rule: shops list every hull the tech can build except +the size you sail. A bigger hull with your ship AT THE QUAY is still the +trade-in (same record, cargo stays, old hull broken up); anything else - +ship elsewhere, or a smaller hull - is bought OUTRIGHT: new boat in hand, +old boat left unowned where it lies with its cargo, first come first +served. The purchase gets the same confirmation dialog a boat capture does, +because it is the same decision. `refit-needs-ship` is gone; the abandoned +hull's plate flips to UNOWNED via the identity relabel. + +## Interstice resources — the nether plan built (2026-08-05) + +`tradewinds-interstice-plan.md` N1+N2+N3 are IN, one session after drafting. +The interstice is now a place worth lingering in when fate drops you there - +entry stays by-accident, the free re-engage stays free, and everything +gathered already had a price. + +- **`galaxy/IntersticeMap`** - pure seeded geometry (no Bukkit, headless + tests): wart shoal cells, grand-shoal rolls, watchtower cells, the shoal + dome function, and `isOpenWater` for stranding safety. Salted off the same + seed as the interstice sea floor. +- **Generator**: shoals rise through `floorTopAt` (max of natural floor and + the dome - crown 2 proud of the sea, rim submerged), and interstice LAND + columns are soul sand through the crown, because wart plants only on soul + sand. +- **Decorator** (`IntersticeDecorator`, grown from the brazier populator): + wart crops (age-randomised) on shoal tops; crimson/warped groves on grand + shoals (hand-rolled stem+cap+shroomlight trees); glowstone blobs hanging + from the ceiling lid; quartz ore in shoal cores and brazier roots; blaze + pickets (nether-brick crow's nest + BLAZE spawner) on a config fraction of + braziers; and wither watchtowers - 5x5 nether-brick towers, WITHER_SKELETON + spawner room at the waterline with a doorway and a soul-sand pile, sealed + loot floor above it with a chest on the vanilla + `minecraft:chests/bastion_treasure` table (config key), crenellated open + top. Netherite stays loot-only, exactly per plan. +- **Stranding safety**: the interstice has LAND now, so + `SeaArrival.openSeaNear` gained a column-test variant and `strand()` feeds + it `IntersticeMap::isOpenWater` - a failed warp can never materialise a + sailor inside a shoal or a tower wall (the suffocation bug's third + cousin, pre-empted this time). +- **Booty**: NETHER_WART and GHAST_TEAR joined the encounter booty table - + the trickle for sailors who never fail a warp. +- All knobs in `config.yml` under `interstice.*`; the placement ones are + `needsReset` - **regenerate `tradewinds_world_nether`** to see features. +- Deferred from the plan: magma cubes (texture only; fire resistance is + craftable), secondhand-shelf seeding, price spot-checks (N3 notes). + +Implementation facts worth keeping: `LimitedRegion` HAS +`getBlockState(int,int,int)` - spawner/chest tile config in a populator is +setType, getBlockState, mutate, update(). Loot tables resolve through +`Bukkit.getLootTable(NamespacedKey)` and a bad config key logs and leaves a +plain chest. + +**Same session, the orphan batch button.** Playtest screenshot: the +outfitter showed a bare "x16 - $7,184" no one could attribute. It was the +COMPASS batch (449 x 16) - unstackable gear (the fishing rod) gets no batch +button, which shifts the dialog's two-column pairing, and `outfit-batch` +labels carried no item name, so the orphan sold a mystery. Fix: the batch +label now names its good ("Compass x16 - $7,184"). Fourth member of the +artifact-drift family in spirit: a layout assumption (pairs) and a label +assumption (context from the neighbour) that individually were fine and +together broke the moment the shelf gained one unstackable item. + +## Pirates abandon ship on their first tick, and the interstice plan (2026-08-05) + +**The pirate crew never rode its boat.** Playtest: pillagers "appear outside +the boat and paddle toward me". They WERE seated - then ejected within a +second, because `hunt()`'s abandon-ship radius (flat 30) was WIDER than the +encounter spawn distance (28): every crew spawned already inside it and went +over the side on the first tick, and the boat drifted away empty. The wide +abandon exists for the SEA_WITCH (potions overshoot from a drifting +platform); pirates got swept up in it. Fix: abandon-ship is per encounter +type in config (`encounters.abandon-ship`: PIRATE_CREW 10, SEA_WITCH 30) and +the encounter PDC tag now records the TYPE, not just "encounter". Playtest +check: whether pillagers actually fire crossbows from the deck on 26.2 - if +not, raise their number back toward 30 in config and the boat is arrival +theatre only. + +**Interstice resources are planned, not built** - see +`tradewinds-interstice-plan.md` (normative when work starts): wart shoals, +blaze pickets, glowstone ceiling, quartz spires, wither watchtowers; +netherite loot-only; End goods trade-only; entry stays by-accident +(consolation-prize model). **Shipped now:** lighting a portal frame in either +TradeWinds world prints the lore refusal instead of failing silently +(`tradewinds.interstice.no-portal`) - ruined portals are common enough that +players were reading the silence as a bug. + +## Stage 7b — living with a base (2026-08-05) + +Closes Stage 7. Four features, all riding on 7a's claims: + +- **Member-only warp node.** A claimed islet travels as a SYNTHETIC + `IslandSpec` (`travel/HomePort`): its wild-grid cell, its real centre, and + tech level -1 as the home sentinel (`HomePort.isHome`). Members get it + pinned first in every warp dialog with its own labels (no port sheet - a + home has no type or tech); nobody else ever sees it. Warping OUT triggers + at the claim's protection border exactly like a port's ring + (`BorderPromptListener.homeBorder`). Arrival aims the boat at the islet + centre - there is no pier to steer for. Normal fuel rules throughout. + **Interstice trap dodged:** the owed-destination map stores galaxy CELLS, + and a home's wild-grid cell would re-resolve to a wrong (or no) trading + island - so a failed home warp stores the `"home"` marker instead and + re-resolves through the player's island at re-engage; if they unclaimed + while stranded, the nearest-charted fallback still applies. Nobody is + ever stranded. +- **Course-home mode.** `/tw go` at sea (with an island) now TOGGLES a + persistent course bar (`NavigationBarTask.showCourse`): by day the sun + gives only the eight-point direction; by night the stars add the exact + distance and the bar fills as home approaches (`hud.course-bar-scale`). + Island waters still take precedence over the course bar; making landfall + in your own waters clears the course by itself. The chart holograms + gained a gold HOME marker, and the Star Chart draws the claim as a gold + dot named Home (or the island's admin-given name) for every member. +- **`/tw unclaim`.** Owner only, confirmation required, refuses while the + team has members (kicking the crew is part of the decision), no refund. + Uses BentoBox `hardDeleteIsland` - record gone, grid freed, every block + stays - so the islet is claimable again the moment it returns. Only + islands carrying the `tradewinds-claim` metadata may be dropped: the + spawn island is not yours to unclaim. +- **Ship's compass.** Sold at FISHING outfitters (rod and compass - the + fisheries are the navigator's shop, and the default spawn island is one): + a compass bought by an island member leaves the counter lodestone-bound + to their island with tracking OFF, so it points home from anywhere with + no lodestone block needed. For everyone else it is a plain compass - + which points at world spawn, the spawn port. Not on every shelf because + INDUSTRIAL's armour row already fills the 8-slot dialog exactly + (`SaleCatalogTest.testOutfitterFitsTheDialog` is the guard that caught it). + +## The moored-refit bug: find boats by identity, never by position box (2026-08-04) + +Playtest: exit your oak boat at the dock, buy a spruce hull at the trader, +walk back - still an oak boat. The record said spruce (the exact +record-over-wrong-hull mismatch `refit`'s own javadoc warns about). Cause: +the moored branch searched a **6-block box around the last-remembered +position** - but dismounted boats glide and drift, and the remembered spot +goes stale. Same fragile pattern sat in `relabel` (a 4-block box) and in +`boatIsHere`'s fallback (trusted the remembered position outright, so the +yard could take money for a hull it could not reach). + +Fix: new `BoatService.findPlaced(hold)` - finds the record's placed avatar +(Boat entity, or dropped item carrying the stamp) by **identity** across the +world's loaded entities, and re-remembers the true position on every hit, so +the chart heals whenever anyone looks. All three callers now go through it: + +- `refit` swaps the hull wherever it actually floats (or fixes the dropped + item in place); position box gone. +- `boatIsHere` judges the boat's TRUE position against the island range - a + refit is only sold when the hull is genuinely at the island (carried in + the pack counts, judged on the player's position). Stricter than before, + and what the design always claimed. +- `relabel` labels whatever it finds, at any distance. + +This is the "match boats by identity, never by material" rule's sibling: +never by position, either. + +## Stage 7a — Seafarer ranks & islet claiming (2026-08-04) + +Stage 7 is wild-islet claiming, not blueprints (decided 2026-08-04, dev plan +updated). This session built the 7a half: the rank ladder, the leaderboard, +and `/tw claim`. + +- **Seafarer ranks** (`travel/RankService`): rank = charted-island count + against `ranks.thresholds` (19 rungs, Deck Hand 0 → Mythic Mariner 100); + names in the locale under `tradewinds.rank.`. Early rungs sit above + the pre-charted starter cluster (5), so the first promotion is earned. + `claims.minimum-rank` names a slug; a typo **fails closed** to the top + threshold, never to zero. +- **Leaderboard + placeholders** (`travel/ChartLeaderboard`): counts seeded + from the DB async at startup, kept fresh by `IslandChartedEvent` (both the + sighting and port-scan paths fire it). `/tw rank` shows rank, next rung and + the top ten; PlaceholderAPI gets `%tradewinds_rank%`, `%tradewinds_charted%`, + `%tradewinds_top_name_1..10%`, `%tradewinds_top_charted_1..10%`. +- **`/tw claim`** (`travel/IsletClaimService`): stand on a wild islet, pass + rank gate + price (`claims.price`, default 150,000 — above the top boat), + and the islet becomes a real BentoBox island: owner set, protection sized + `islet radius + claims.protection-margin`, default flags, metadata + `tradewinds-claim` = "cellX,cellZ,radius" for the 7b warp node. No paste - + the land is already there, and whatever was built on it comes along. + Money is withdrawn only AFTER the grid accepts the island: the insert is + the one step that can still refuse, and a refund path is a bug farm. + +**The pitfall that shaped it: BentoBox's grid refuses overlapping RANGE +boxes, and `createIsland` hands every island the full island-distance box** +(1000 → a 2000-block square). Trading islands therefore reserved ~1.1 km +around every port — no islet claim could ever fit there (~40% of all islets). +Fix in two halves: trading islands now get their range box sized to the +protection range at registration AND adoption (`GalaxyIslandRegistrar +.sizeRangeBox`, which must re-add to the island cache or the grid keeps the +old box until restart), and claims size their box BEFORE the grid insert by +mimicking `createIsland` (`new Island(...)` + `setRange` + cache add) instead +of calling it. Both are legal because TradeWinds overrides +`isEnforceEqualRanges()` to false - the hook was left in place for exactly +this stage. Side effect worth knowing: band SETTING flags (PvP, monster +spawns) now reach exactly the protection range, which is what the config +comments always claimed they did. + +Respawn needed nothing: `ISLAND_RESPAWN` is a BentoBox world flag that +defaults ON, so owners/members respawn at their claim; the islandless still +hit `SpawnRespawnListener`'s spawn-port path. + +**`/tw sethome` and `/tw home`** (same day): both work ONLY while standing +inside your own island's protection range - owner or team member. Ruled by +Ben: homes are a convenience around the base, never a way home across the +sea, so the no-teleport rule stands. Both lean on stock BentoBox home +machinery (`setHomeLocation`, `homeTeleportAsync`); the only TradeWinds code +is the gate, shared as `TWSetHomeCommand.islandUnderfoot`. + +**`/tw go` is now the one "go" verb** (ruled 2026-08-05): from another world +it is still the door into the ocean; standing on your own island it steps +you home (same move as `/tw home`); anywhere else at sea, if you have an +island, it POINTS the way - eight-point bearing plus distance +(`TWSpawnCommand.compassKey`, unit-tested) - and leaves the sailing to you. +The islandless still get the original at-sea refusal. This also settles the +bare-`/tw` question: `default-action: go` now does the right thing +everywhere, and no config change is needed. Direction names live in the +locale under `tradewinds.direction.*` (spelled north/south/..., never bare +`n`/`no` - YAML 1.1 booleans). The 7b course mode (persistent bar, stars at +night) will grow out of the same command. + +**`/twadmin rank [rank|islands|reset]`** (same day): rank is DERIVED +from the charted count, so the admin command stores the *difference* from the +player's real chart as a persistent `chartedBonus` on `TWPlayerData` - +promotion and demotion both work, `reset` clears it, and real charting keeps +counting on top. Every consumer (rank, claim gate, leaderboard, placeholders) +reads one number, `TWPlayerData.effectiveCharted()`, so an admin change is +consistent everywhere at once. Built for testing the claim gate without +sailing 36 islands; kept for event rewards. + +Also this session, pre-7a: **deepslate below Y 0** (vanilla look, deepslate +ore variants free from the decoration pass); and confirmed in play that +vanilla ores DO generate under our generator - `make-decorations: true` is +all it takes. + +Still 7b (planned, not built): `/tw home`-style course-home toggle (NOT +`/tw go`, which is the door into the ocean and already taken), member-only +warp to/from the claim, unclaim (owner command, unregister semantics, team +must be emptied first), ship's compass on the outfitter shelf. + ## Known 26.2 quirk: clocks spin in every plugin world (2026-08-03) Not ours. Clocks spin (Nether-style) in the TradeWinds world AND in BSkyBlock's diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 476ebdd..1e828d3 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -207,6 +207,54 @@ private static Map defaultBandHurtVillagers() { return map; } + /* SEAFARER RANKS & ISLET CLAIMS */ + @ConfigComment("Seafarer rank ladder: rank slug -> charted islands required. A player's rank") + @ConfigComment("is the highest threshold at or below their charted count. Display names live") + @ConfigComment("in the locale under tradewinds.rank. - add a locale name for any new") + @ConfigComment("slug, keep exactly one rank at 0 so every sailor has a rank, and avoid dots") + @ConfigComment("in slugs (YAML nests on them).") + @ConfigEntry(path = "ranks.thresholds") + private Map rankThresholds = defaultRankThresholds(); + + private static Map defaultRankThresholds() { + Map map = new HashMap<>(); + map.put("deck-hand", 0); + map.put("cabin-scout", 6); + map.put("coast-finder", 8); + map.put("bay-explorer", 10); + map.put("island-hopper", 12); + map.put("reef-runner", 15); + map.put("wave-master", 17); + map.put("chart-maker", 20); + map.put("storm-navigator", 25); + map.put("archipelago-ace", 30); + map.put("tide-captain", 36); + map.put("horizon-hunter", 42); + map.put("sea-sentinel", 49); + map.put("salty-commodore", 56); + map.put("ocean-overlord", 64); + map.put("pirate-pathfinder", 72); + map.put("grand-admiral", 81); + map.put("globe-voyager", 90); + map.put("mythic-mariner", 100); + return map; + } + + @ConfigComment("Price of claiming a wild islet, in whole coins. Deliberately above the top") + @ConfigComment("boat (Pale Oak Chest Boat, 110,250 at defaults): the boat ladder comes first.") + @ConfigEntry(path = "claims.price") + private double claimPrice = 150000; + + @ConfigComment("Minimum Seafarer rank (a slug from ranks.thresholds) required to claim a") + @ConfigComment("wild islet. Team members need no rank - only the claimer.") + @ConfigEntry(path = "claims.minimum-rank") + private String claimMinimumRank = "tide-captain"; + + @ConfigComment("Blocks of protected water beyond the islet's terrain radius. The protection") + @ConfigComment("range is islet radius + this margin.") + @ConfigEntry(path = "claims.protection-margin") + private int claimProtectionMargin = 32; + @ConfigComment("Trading islands are public markets: EVERY protection flag is allowed at visitor") @ConfigComment("rank except a built-in deny list (anti-grief, the island's own crops, stores and") @ConfigComment("livestock, and direct villager trading - which would bypass the hold).") @@ -380,6 +428,27 @@ public double bountyFor(Crime crime) { @ConfigEntry(path = "hud.navigation-bossbar") private boolean navigationBossbar = true; + @ConfigComment("Distance in blocks at which the course-home bar (toggled by /tw go at sea)") + @ConfigComment("reads empty at night; it fills as home approaches. By day the bar shows") + @ConfigComment("direction only - the stars are the precise instrument.") + @ConfigEntry(path = "hud.course-bar-scale") + private int courseBarScale = 5000; + + @ConfigComment("How close a player must be (blocks) before a boated encounter crew abandons") + @ConfigComment("ship, per encounter type. Pirates fight from the deck and only jump at") + @ConfigComment("boarding distance; the witch bails wide - potions overshoot from a drifting") + @ConfigComment("platform. Must be BELOW encounters.distance or the crew ejects on its first") + @ConfigComment("tick and nobody ever sees a manned boat.") + @ConfigEntry(path = "encounters.abandon-ship") + private Map encounterAbandonShip = defaultEncounterAbandonShip(); + + private static Map defaultEncounterAbandonShip() { + Map map = new HashMap<>(); + map.put("PIRATE_CREW", 10); + map.put("SEA_WITCH", 30); + return map; + } + /* TRAVEL */ @ConfigComment("Base warp fuel cost multiplier: fuel units per block of Euclidean route distance.") @ConfigComment("Per-edge overrides come later via the route-graph config.") @@ -853,6 +922,53 @@ private static Map defaultBasePrices() { @ConfigEntry(path = "interstice.brazier-chance", needsReset = true) private double intersticeBrazierChance = 0.18; + /* INTERSTICE RESOURCES (see tradewinds-interstice-plan.md) */ + @ConfigComment("Wart shoals: grid size in blocks for the soul-sand banks that break the") + @ConfigComment("interstice surface, chance (0-1) a cell hosts one, and their mean radius.") + @ConfigComment("0 chance or radius disables them.") + @ConfigEntry(path = "interstice.shoal-grid", needsReset = true) + private int intersticeShoalGrid = 256; + + @ConfigEntry(path = "interstice.shoal-chance", needsReset = true) + private double intersticeShoalChance = 0.5; + + @ConfigEntry(path = "interstice.shoal-radius", needsReset = true) + private int intersticeShoalRadius = 9; + + @ConfigComment("Chance (0-1) a shoal is a GRAND shoal carrying a crimson or warped grove.") + @ConfigEntry(path = "interstice.grand-shoal-chance", needsReset = true) + private double intersticeGrandShoalChance = 0.2; + + @ConfigComment("Fraction of a shoal's soul-sand surface planted with nether wart.") + @ConfigEntry(path = "interstice.wart-density") + private double intersticeWartDensity = 0.35; + + @ConfigComment("Chance (0-1) that a brazier grows a nether-brick turret with a blaze") + @ConfigComment("spawner - the interstice's blaze pickets.") + @ConfigEntry(path = "interstice.blaze-picket-chance") + private double intersticeBlazePicketChance = 0.25; + + @ConfigComment("Chance per ceiling chunk of a glowstone cluster hanging from the lid -") + @ConfigComment("light as navigation and destination at once. Needs ceiling-height > 0.") + @ConfigEntry(path = "interstice.glowstone-cluster-chance") + private double intersticeGlowstoneClusterChance = 0.2; + + @ConfigComment("Chance per submerged shoal-core or brazier-root block of quartz ore.") + @ConfigEntry(path = "interstice.quartz-chance") + private double intersticeQuartzChance = 0.12; + + @ConfigComment("Wither watchtowers: grid size in blocks and chance (0-1) a cell hosts one -") + @ConfigComment("the interstice's only structure. Wither skeletons spawn inside; the loot") + @ConfigComment("chest uses the vanilla loot table named below.") + @ConfigEntry(path = "interstice.watchtower-grid", needsReset = true) + private int intersticeWatchtowerGrid = 1536; + + @ConfigEntry(path = "interstice.watchtower-chance", needsReset = true) + private double intersticeWatchtowerChance = 0.6; + + @ConfigEntry(path = "interstice.watchtower-loot-table") + private String intersticeWatchtowerLootTable = "minecraft:chests/bastion_treasure"; + @ConfigComment("Ghasts spawned around a stranded sailor in the interstice.") @ConfigEntry(path = "interstice.ghasts-min") private int intersticeGhastsMin = 1; @@ -907,7 +1023,8 @@ private static Map defaultEncounterChance() { @ConfigComment("fighting is the third way to earn, beside trading and smuggling.") @ConfigEntry(path = "encounters.booty-table") private List bootyTable = new ArrayList<>(List.of("NAUTILUS_SHELL", "PRISMARINE_SHARD", - "PRISMARINE_CRYSTALS", "GOLD_INGOT", "IRON_INGOT", "EMERALD", "COAL", "COOKED_COD", "TRIDENT")); + "PRISMARINE_CRYSTALS", "GOLD_INGOT", "IRON_INGOT", "EMERALD", "COAL", "COOKED_COD", "TRIDENT", + "NETHER_WART", "GHAST_TEAR")); /* ILLEGAL TRADE */ @ConfigComment("Master gate for all illegal-goods mechanics: contraband, customs scans, smuggling.") @@ -3113,6 +3230,14 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setPortAllowedFlags(List portAllowedFlags) { this.portAllowedFlags = portAllowedFlags; } public Map getBandHurtVillagersRank() { return bandHurtVillagersRank; } public void setBandHurtVillagersRank(Map bandHurtVillagersRank) { this.bandHurtVillagersRank = bandHurtVillagersRank; } + public Map getRankThresholds() { return rankThresholds; } + public void setRankThresholds(Map rankThresholds) { this.rankThresholds = rankThresholds; } + public double getClaimPrice() { return claimPrice; } + public void setClaimPrice(double claimPrice) { this.claimPrice = claimPrice; } + public String getClaimMinimumRank() { return claimMinimumRank; } + public void setClaimMinimumRank(String claimMinimumRank) { this.claimMinimumRank = claimMinimumRank; } + public int getClaimProtectionMargin() { return claimProtectionMargin; } + public void setClaimProtectionMargin(int claimProtectionMargin) { this.claimProtectionMargin = claimProtectionMargin; } public int getResidentTetherRadius() { return residentTetherRadius; } public void setResidentTetherRadius(int residentTetherRadius) { this.residentTetherRadius = residentTetherRadius; } public int getResidentRespawnDelayMinutes() { return residentRespawnDelayMinutes; } @@ -3134,6 +3259,10 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public int getStarChartBlocksPerPixel() { return starChartBlocksPerPixel; } public void setStarChartBlocksPerPixel(int starChartBlocksPerPixel) { this.starChartBlocksPerPixel = starChartBlocksPerPixel; } public boolean isNavigationBossbar() { return navigationBossbar; } + public int getCourseBarScale() { return courseBarScale; } + public void setCourseBarScale(int courseBarScale) { this.courseBarScale = courseBarScale; } + public Map getEncounterAbandonShip() { return encounterAbandonShip; } + public void setEncounterAbandonShip(Map encounterAbandonShip) { this.encounterAbandonShip = encounterAbandonShip; } public void setNavigationBossbar(boolean navigationBossbar) { this.navigationBossbar = navigationBossbar; } public double getStartingBalance() { return startingBalance; } public void setStartingBalance(double startingBalance) { this.startingBalance = startingBalance; } @@ -3225,6 +3354,28 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setIntersticeCeilingHeight(int v) { this.intersticeCeilingHeight = v; } public double getIntersticeBrazierChance() { return intersticeBrazierChance; } public void setIntersticeBrazierChance(double v) { this.intersticeBrazierChance = v; } + public int getIntersticeShoalGrid() { return intersticeShoalGrid; } + public void setIntersticeShoalGrid(int v) { this.intersticeShoalGrid = v; } + public double getIntersticeShoalChance() { return intersticeShoalChance; } + public void setIntersticeShoalChance(double v) { this.intersticeShoalChance = v; } + public int getIntersticeShoalRadius() { return intersticeShoalRadius; } + public void setIntersticeShoalRadius(int v) { this.intersticeShoalRadius = v; } + public double getIntersticeGrandShoalChance() { return intersticeGrandShoalChance; } + public void setIntersticeGrandShoalChance(double v) { this.intersticeGrandShoalChance = v; } + public double getIntersticeWartDensity() { return intersticeWartDensity; } + public void setIntersticeWartDensity(double v) { this.intersticeWartDensity = v; } + public double getIntersticeBlazePicketChance() { return intersticeBlazePicketChance; } + public void setIntersticeBlazePicketChance(double v) { this.intersticeBlazePicketChance = v; } + public double getIntersticeGlowstoneClusterChance() { return intersticeGlowstoneClusterChance; } + public void setIntersticeGlowstoneClusterChance(double v) { this.intersticeGlowstoneClusterChance = v; } + public double getIntersticeQuartzChance() { return intersticeQuartzChance; } + public void setIntersticeQuartzChance(double v) { this.intersticeQuartzChance = v; } + public int getIntersticeWatchtowerGrid() { return intersticeWatchtowerGrid; } + public void setIntersticeWatchtowerGrid(int v) { this.intersticeWatchtowerGrid = v; } + public double getIntersticeWatchtowerChance() { return intersticeWatchtowerChance; } + public void setIntersticeWatchtowerChance(double v) { this.intersticeWatchtowerChance = v; } + public String getIntersticeWatchtowerLootTable() { return intersticeWatchtowerLootTable; } + public void setIntersticeWatchtowerLootTable(String v) { this.intersticeWatchtowerLootTable = v; } public int getIntersticeGraceSeconds() { return intersticeGraceSeconds; } public void setIntersticeGraceSeconds(int v) { this.intersticeGraceSeconds = v; } public int getIntersticeGhastsMin() { return intersticeGhastsMin; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index afc9997..60b5834 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -27,17 +27,23 @@ import world.bentobox.bentobox.lists.Flags; import world.bentobox.tradewinds.commands.AdminCustomsCommand; import world.bentobox.tradewinds.commands.AdminPriceAuditCommand; +import world.bentobox.tradewinds.commands.AdminRankCommand; import world.bentobox.tradewinds.commands.AdminIslandsCommand; import world.bentobox.tradewinds.commands.AdminReflagCommand; import world.bentobox.tradewinds.commands.AdminTpIslandCommand; import world.bentobox.tradewinds.commands.AdminWarpFailCommand; import world.bentobox.tradewinds.commands.TWChartCommand; +import world.bentobox.tradewinds.commands.TWClaimCommand; import world.bentobox.tradewinds.commands.TWPricesCommand; +import world.bentobox.tradewinds.commands.TWRankCommand; import world.bentobox.tradewinds.commands.TWFineCommand; +import world.bentobox.tradewinds.commands.TWHomeCommand; import world.bentobox.tradewinds.commands.TWRestartCommand; +import world.bentobox.tradewinds.commands.TWSetHomeCommand; import world.bentobox.tradewinds.commands.TWSpawnCommand; import world.bentobox.tradewinds.commands.TWStarChartCommand; import world.bentobox.tradewinds.commands.TWTradeCommand; +import world.bentobox.tradewinds.commands.TWUnclaimCommand; import world.bentobox.tradewinds.commands.TWWarpCommand; import world.bentobox.tradewinds.crime.BountyBoard; import world.bentobox.tradewinds.crime.CrimeListener; @@ -119,6 +125,10 @@ public class TradeWinds extends GameModeAddon { private TradeDialog tradeDialog; private ChartHolograms chartHolograms; private StarChartService starChartService; + private world.bentobox.tradewinds.travel.RankService rankService; + private world.bentobox.tradewinds.travel.ChartLeaderboard chartLeaderboard; + private world.bentobox.tradewinds.travel.IsletClaimService isletClaimService; + private world.bentobox.tradewinds.galaxy.IntersticeMap intersticeMap; private IntersticeService intersticeService; private @Nullable EncounterService encounterService; private ReputationService reputationService; @@ -198,6 +208,13 @@ public void setup() { new TWTradeCommand(this); new TWRestartCommand(this); new TWFineCommand(this); + new TWRankCommand(this); + new TWClaimCommand(this); + // Home commands work only INSIDE your own island's protection + // range - a convenience around the base, never a way home + new TWSetHomeCommand(this); + new TWHomeCommand(this); + new TWUnclaimCommand(this); new IslandInfoCommand(this); new IslandSettingsCommand(this); new IslandLanguageCommand(this); @@ -213,6 +230,7 @@ public void setup() { new AdminCustomsCommand(this); new AdminWarpFailCommand(this); new AdminPriceAuditCommand(this); + new AdminRankCommand(this); } }; } @@ -293,6 +311,13 @@ public void onEnable() { starChartService = new StarChartService(this); registerListener(starChartService); registerListener(new ChartingListener(this)); + // Seafarer ranks and the charted-islands leaderboard (Stage 7) + rankService = new world.bentobox.tradewinds.travel.RankService(this); + chartLeaderboard = new world.bentobox.tradewinds.travel.ChartLeaderboard(this); + chartLeaderboard.seed(); + chartLeaderboard.registerPlaceholders(rankService); + registerListener(chartLeaderboard); + isletClaimService = new world.bentobox.tradewinds.travel.IsletClaimService(this, rankService); registerListener(new BorderPromptListener(this)); // Teleporting while boated brings the boat (and cargo) along registerListener(new BoatPickupListener(this)); @@ -517,7 +542,7 @@ private void bootstrapSpawnIsland() { // Adopting an island from an older build: give it the full trading // island geometry it should have had spawn.setProtectionRange(getSettings().getIslandProtectionRange()); - spawn.setRange(getSettings().getIslandDistance()); + spawn.setRange(getSettings().getIslandProtectionRange()); spawn.setName(spec.name()); getIslands().setSpawn(spawn); log("Designated " + spec.name() + " (" + spec.type() + ") as the spawn island"); @@ -536,6 +561,22 @@ public PlayerDataManager getPlayerDataManager() { return playerDataManager; } + public world.bentobox.tradewinds.travel.RankService getRankService() { + return rankService; + } + + public world.bentobox.tradewinds.travel.ChartLeaderboard getChartLeaderboard() { + return chartLeaderboard; + } + + public world.bentobox.tradewinds.travel.IsletClaimService getIsletClaimService() { + return isletClaimService; + } + + public NavigationBarTask getNavigationBarTask() { + return navigationBarTask; + } + public FuelService getFuelService() { return fuelService; } @@ -690,6 +731,25 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { return galaxyEngine; } + /** + * The interstice's feature map (wart shoals, watchtowers) - pure seeded + * geometry, salted off the same seed the interstice sea floor uses so one + * number still decides the whole world. + * + * @param worldSeed the interstice world's seed + * @return the map + */ + public world.bentobox.tradewinds.galaxy.IntersticeMap getIntersticeMap(long worldSeed) { + if (intersticeMap == null) { + Settings s = getSettings(); + intersticeMap = new world.bentobox.tradewinds.galaxy.IntersticeMap(worldSeed ^ 0x1E7E2571CEL, + s.getIntersticeShoalGrid(), s.getIntersticeShoalChance(), s.getIntersticeShoalRadius(), + s.getIntersticeGrandShoalChance(), s.getIntersticeWatchtowerGrid(), + s.getIntersticeWatchtowerChance()); + } + return intersticeMap; + } + /** * The configured shape of the sea floor, or a flat floor at the shelf depth * when {@code world.seabed.vary} is off. diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminRankCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminRankCommand.java new file mode 100644 index 0000000..da5b40d --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminRankCommand.java @@ -0,0 +1,101 @@ +package world.bentobox.tradewinds.commands; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.travel.RankService; + +/** + * {@code /twadmin rank [rank|islands|reset]} - inspect or set a + * player's Seafarer rank. + *

+ * Rank is derived from the charted-island count, so "setting a rank" stores + * the DIFFERENCE from the player's real chart as a persistent adjustment: + * promotion and demotion both work, real charting keeps counting on top, and + * the claim gate, `/tw rank` and the leaderboard all agree because they all + * read the same effective count. Written for testing the Stage 7 claim gate + * without sailing 36 islands, and kept for live servers - rewarding an event + * winner, or undoing a mistaken reward. + * + * @author tastybento + */ +public class AdminRankCommand extends CompositeCommand { + + public AdminRankCommand(CompositeCommand parent) { + super(parent, "rank"); + } + + @Override + public void setup() { + setPermission("admin.rank"); + setOnlyPlayer(false); + setParametersHelp("tradewinds.commands.admin.rank.parameters"); + setDescription("tradewinds.commands.admin.rank.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.isEmpty() || args.size() > 2) { + showHelp(this, user); + return false; + } + UUID target = getPlayers().getUUID(args.get(0)); + if (target == null) { + user.sendMessage("general.errors.unknown-player", TextVariables.NAME, args.get(0)); + return false; + } + TradeWinds addon = getAddon(); + RankService ranks = addon.getRankService(); + if (args.size() == 1) { + report(user, addon, ranks, target, args.get(0)); + return true; + } + String wanted = args.get(1); + if (wanted.equalsIgnoreCase("reset")) { + ranks.clearAdjustment(target); + } else if (wanted.chars().allMatch(Character::isDigit)) { + ranks.setEffectiveCharted(target, Integer.parseInt(wanted)); + } else { + Optional rank = ranks.bySlug(wanted); + if (rank.isEmpty()) { + user.sendMessage("tradewinds.commands.admin.rank.unknown-rank", TextVariables.NAME, wanted); + return false; + } + ranks.setEffectiveCharted(target, rank.get().threshold()); + } + addon.getChartLeaderboard().refresh(target); + report(user, addon, ranks, target, args.get(0)); + return true; + } + + private void report(User user, TradeWinds addon, RankService ranks, UUID target, String name) { + var data = addon.getPlayerDataManager().get(target); + user.sendMessage("tradewinds.commands.admin.rank.current", + TextVariables.NAME, getPlayers().getName(target) == null ? name : getPlayers().getName(target), + "[rank]", user.getTranslation(ranks.rankFor(data.effectiveCharted()).localeKey()), + TextVariables.NUMBER, String.valueOf(data.effectiveCharted()), + "[real]", String.valueOf(data.getChartedIslands().size()), + "[bonus]", String.valueOf(data.getChartedBonus())); + } + + @Override + public Optional> tabComplete(User user, String alias, List args) { + // args is the RAW argument chain including this command's own label: + // "/twadmin rank " arrives as ["rank", ""] + if (args.size() <= 2) { + return Optional.of(Util.getOnlinePlayerList(user)); + } + TradeWinds addon = getAddon(); + List options = new ArrayList<>( + addon.getRankService().ladder().stream().map(RankService.Rank::slug).toList()); + options.add("reset"); + return Optional.of(Util.tabLimit(options, args.get(args.size() - 1))); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWClaimCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWClaimCommand.java new file mode 100644 index 0000000..7f0b239 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWClaimCommand.java @@ -0,0 +1,64 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.economy.Money; +import world.bentobox.tradewinds.travel.IsletClaimService; + +/** + * {@code /tw claim} - claim the wild islet you are standing on. The gate is + * Seafarer rank (earned by charting) and price (config); everything else is + * ordinary BentoBox island life from the moment it succeeds. + * + * @author tastybento + */ +public class TWClaimCommand extends CompositeCommand { + + public TWClaimCommand(CompositeCommand parent) { + super(parent, "claim"); + } + + @Override + public void setup() { + setPermission("island.claim"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.claim.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + if (!user.getWorld().equals(addon.getOverWorld())) { + user.sendMessage("tradewinds.claim.not-on-islet"); + return false; + } + IsletClaimService.Result result = addon.getIsletClaimService().claim(user.getPlayer()); + if (result.success()) { + user.sendMessage("tradewinds.claim.success", + "[price]", Money.format(addon, addon.getSettings().getClaimPrice())); + return true; + } + switch (result.refusal()) { + case NOT_ON_ISLET -> user.sendMessage("tradewinds.claim.not-on-islet"); + case ALREADY_CLAIMED -> user.sendMessage("tradewinds.claim.already-claimed"); + case HAS_ISLAND -> user.sendMessage("tradewinds.claim.has-island"); + case RANK_TOO_LOW -> { + var ranks = addon.getRankService(); + int needed = ranks.claimThreshold(); + user.sendMessage("tradewinds.claim.rank-too-low", + "[rank]", user.getTranslation(ranks.rankFor(needed).localeKey()), + TextVariables.NUMBER, String.valueOf(needed), + "[charted]", String.valueOf(ranks.chartedCount(user.getUniqueId()))); + } + case NO_ECONOMY -> user.sendMessage("tradewinds.claim.no-economy"); + case CANNOT_AFFORD -> user.sendMessage("tradewinds.claim.cannot-afford", + "[price]", Money.format(addon, addon.getSettings().getClaimPrice())); + case WATERS_TAKEN -> user.sendMessage("tradewinds.claim.waters-taken"); + } + return false; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWHomeCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWHomeCommand.java new file mode 100644 index 0000000..46d0b93 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWHomeCommand.java @@ -0,0 +1,54 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; +import java.util.Optional; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.tradewinds.TradeWinds; + +/** + * {@code /tw home} - step to your home point, while already on your island. + *

+ * This is NOT a way home across the sea: it refuses anywhere but inside your + * own island's protection range (owner or team member), so the only travel it + * saves is the walk up your own beach. Getting to the island in the first + * place is still sailing or a warp - the no-teleport rule stands. + * + * @author tastybento + */ +public class TWHomeCommand extends CompositeCommand { + + public TWHomeCommand(CompositeCommand parent) { + super(parent, "home"); + } + + @Override + public void setup() { + setPermission("island.home"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.home.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + if (addon.getIslands().getIsland(addon.getOverWorld(), user.getUniqueId()) == null) { + user.sendMessage("tradewinds.home.no-island"); + return false; + } + Optional island = TWSetHomeCommand.islandUnderfoot(addon, user); + if (island.isEmpty()) { + user.sendMessage("tradewinds.home.not-on-island"); + return false; + } + addon.getIslands().homeTeleportAsync(addon.getOverWorld(), user.getPlayer()) + .thenAccept(done -> { + if (Boolean.TRUE.equals(done)) { + user.sendMessage("tradewinds.home.teleported"); + } + }); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWRankCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWRankCommand.java new file mode 100644 index 0000000..98ab107 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWRankCommand.java @@ -0,0 +1,64 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.travel.ChartLeaderboard; +import world.bentobox.tradewinds.travel.RankService; + +/** + * {@code /tw rank} - your Seafarer rank, the next rung, and the top ten + * sailors by islands charted. The kid-readable face of the rank ladder; + * scoreboard plugins get the same numbers via placeholders. + * + * @author tastybento + */ +public class TWRankCommand extends CompositeCommand { + + /** Rows shown from the leaderboard. */ + static final int TOP = 10; + + public TWRankCommand(CompositeCommand parent) { + super(parent, "rank", "ranks"); + } + + @Override + public void setup() { + setPermission("island.rank"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.rank.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + RankService ranks = addon.getRankService(); + int charted = ranks.chartedCount(user.getUniqueId()); + user.sendMessage("tradewinds.rank.own", + "[rank]", user.getTranslation(ranks.rankFor(charted).localeKey()), + TextVariables.NUMBER, String.valueOf(charted)); + ranks.next(charted).ifPresentOrElse( + next -> user.sendMessage("tradewinds.rank.next", + "[rank]", user.getTranslation(next.localeKey()), + TextVariables.NUMBER, String.valueOf(next.threshold() - charted)), + () -> user.sendMessage("tradewinds.rank.top-of-ladder")); + List board = addon.getChartLeaderboard().top(TOP); + if (board.isEmpty()) { + return true; + } + user.sendMessage("tradewinds.rank.board-header"); + for (int place = 1; place <= board.size(); place++) { + ChartLeaderboard.Entry entry = board.get(place - 1); + String name = addon.getPlayers().getName(entry.playerId()); + user.sendMessage("tradewinds.rank.board-entry", + "[place]", String.valueOf(place), + TextVariables.NAME, name == null || name.isEmpty() ? "?" : name, + TextVariables.NUMBER, String.valueOf(entry.charted()), + "[rank]", user.getTranslation(ranks.rankFor(entry.charted()).localeKey())); + } + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWSetHomeCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWSetHomeCommand.java new file mode 100644 index 0000000..6057001 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWSetHomeCommand.java @@ -0,0 +1,63 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; +import java.util.Optional; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.tradewinds.TradeWinds; + +/** + * {@code /tw sethome} - move your home point around your claimed islet. + *

+ * Works only while standing INSIDE your own island's protection range, owner + * and team members alike. That boundary is what keeps homes honest under the + * no-teleport rule: you can arrange your base, but you cannot plant a home + * somewhere you would then need a teleport to reach. + * + * @author tastybento + */ +public class TWSetHomeCommand extends CompositeCommand { + + public TWSetHomeCommand(CompositeCommand parent) { + super(parent, "sethome"); + } + + @Override + public void setup() { + setPermission("island.sethome"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.sethome.description"); + } + + /** + * The island the player belongs to, if they are standing inside its + * protection range right now - the shared gate for {@code /tw sethome} + * and {@code /tw home}. Owner or team member, no difference. + */ + static Optional islandUnderfoot(TradeWinds addon, User user) { + Island island = addon.getIslands().getIsland(addon.getOverWorld(), user.getUniqueId()); + if (island == null || !island.getMemberSet().contains(user.getUniqueId())) { + return Optional.empty(); + } + return island.onIsland(user.getLocation()) ? Optional.of(island) : Optional.empty(); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + if (addon.getIslands().getIsland(addon.getOverWorld(), user.getUniqueId()) == null) { + user.sendMessage("tradewinds.home.no-island"); + return false; + } + Optional island = islandUnderfoot(addon, user); + if (island.isEmpty()) { + user.sendMessage("tradewinds.home.not-on-island"); + return false; + } + addon.getIslands().setHomeLocation(user, user.getLocation()); + user.sendMessage("tradewinds.home.set"); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java index 52cba91..e839233 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java @@ -20,12 +20,16 @@ * simply deleting it locked new players out, and locked anyone in another world * out with them. *

- * So it is a door with two rules. It refuses to do anything if you are - * already at sea - travel is the game, and there is no commanding your - * way across it. And coming in from outside returns you to the water you - * left, not to spawn, so that hopping to another game mode and back is - * not a free ride home either. Only a sailor who has never set out starts at - * the spawn port. + * So it is a door with two rules. At sea it never moves you toward anything - + * travel is the game, and there is no commanding your way across it. And + * coming in from outside returns you to the water you left, not to + * spawn, so that hopping to another game mode and back is not a free ride + * home either. Only a sailor who has never set out starts at the spawn port. + *

+ * Since Stage 7a it is also the "go" of going home - within the rules. + * Standing on your own island, {@code /tw go} steps you to your home point + * (the same move as {@code /tw home}); anywhere else in the ocean it tells + * you the WAY home - direction and distance - and leaves the sailing to you. * * @author tastybento */ @@ -46,9 +50,7 @@ public void setup() { public boolean execute(User user, String label, List args) { TradeWinds addon = getAddon(); if (addon.inWorld(user.getWorld())) { - // Already out there: the way to anywhere is a boat or a warp - user.sendMessage("tradewinds.spawn.already-at-sea"); - return false; + return goAtSea(addon, user); } Optional last = addon.getSeaPositionTracker().lastKnown(user.getPlayer()); Location destination = last.orElseGet(() -> getWorld().getSpawnLocation()); @@ -60,4 +62,56 @@ public boolean execute(User user, String label, List args) { })); return true; } + + /** + * {@code /tw go} inside the ocean world (ruled 2026-08-05): standing on + * your own island it steps you home; away from it, it points the way - + * direction and distance, never a ride. The islandless get the original + * refusal: the way to anywhere is a boat or a warp. + */ + private boolean goAtSea(TradeWinds addon, User user) { + var island = addon.getIslands().getIsland(addon.getOverWorld(), user.getUniqueId()); + if (island == null || !island.getMemberSet().contains(user.getUniqueId())) { + user.sendMessage("tradewinds.spawn.already-at-sea"); + return false; + } + if (island.onIsland(user.getLocation())) { + this.delayCommand(user, + () -> addon.getIslands().homeTeleportAsync(addon.getOverWorld(), user.getPlayer()) + .thenAccept(done -> { + if (Boolean.TRUE.equals(done)) { + user.sendMessage("tradewinds.home.teleported"); + } + })); + return true; + } + // Away from the island: toggle the course-home bar (Stage 7b) and + // give the one-shot bearing right away so the answer is immediate + boolean on = addon.getNavigationBarTask() != null + && addon.getNavigationBarTask().toggleCourse(user.getPlayer()); + if (!on) { + user.sendMessage("tradewinds.home.course-off"); + return true; + } + int dx = island.getCenter().getBlockX() - user.getLocation().getBlockX(); + int dz = island.getCenter().getBlockZ() - user.getLocation().getBlockZ(); + user.sendMessage("tradewinds.home.bearing", + "[direction]", user.getTranslation(compassKey(dx, dz)), + world.bentobox.bentobox.api.localization.TextVariables.NUMBER, + String.valueOf(Math.round(Math.sqrt((double) dx * dx + (double) dz * dz)))); + user.sendMessage("tradewinds.home.course-on"); + return true; + } + + /** + * The eight-point compass direction of an offset, as a locale key - + * north is -Z, east is +X, the Minecraft way. + */ + public static String compassKey(int dx, int dz) { + String[] points = { "north", "north-east", "east", "south-east", "south", "south-west", "west", + "north-west" }; + double angle = Math.toDegrees(Math.atan2(dx, -dz)); + int index = (int) Math.floorMod(Math.round(angle / 45.0), 8); + return "tradewinds.direction." + points[index]; + } } diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWUnclaimCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWUnclaimCommand.java new file mode 100644 index 0000000..bcf39cd --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/TWUnclaimCommand.java @@ -0,0 +1,65 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.commands.ConfirmableCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.travel.IsletClaimService; + +/** + * {@code /tw unclaim} - give a claimed islet back to the wild. + *

+ * Owner only, and deliberately a pain (ruled 2026-08-04): the team must be + * emptied first - kicking your crew is part of the decision, not a side + * effect. No refund. The island record is removed the admin-unregister way - + * every block stays exactly as it was left - and the islet becomes claimable + * by the next sailor who lands on it. + * + * @author tastybento + */ +public class TWUnclaimCommand extends ConfirmableCommand { + + public TWUnclaimCommand(CompositeCommand parent) { + super(parent, "unclaim"); + } + + @Override + public void setup() { + setPermission("island.unclaim"); + setOnlyPlayer(true); + setDescription("tradewinds.commands.unclaim.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + TradeWinds addon = getAddon(); + Island island = addon.getIslands().getIsland(addon.getOverWorld(), user.getUniqueId()); + if (island == null) { + user.sendMessage("tradewinds.home.no-island"); + return false; + } + if (!user.getUniqueId().equals(island.getOwner())) { + user.sendMessage("tradewinds.unclaim.owner-only"); + return false; + } + if (island.getMetaData(IsletClaimService.META_CLAIM).isEmpty()) { + // Not a claimed islet (the spawn island, say): not ours to drop + user.sendMessage("tradewinds.unclaim.not-a-claim"); + return false; + } + if (island.getMemberSet().size() > 1) { + user.sendMessage("tradewinds.unclaim.team-first"); + return false; + } + askConfirmation(user, user.getTranslation("tradewinds.unclaim.confirm"), () -> { + // Unregister semantics: record gone, grid freed, every block + // stays. The islet is claimable again the moment this returns. + addon.getIslands().hardDeleteIsland(island); + user.sendMessage("tradewinds.unclaim.done"); + }); + return true; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java index b6d5184..ce780bd 100644 --- a/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java +++ b/src/main/java/world/bentobox/tradewinds/dataobjects/TWPlayerData.java @@ -44,6 +44,13 @@ public class TWPlayerData implements DataObject { @Expose private Set chartedIslands = new HashSet<>(); + /** + * Admin adjustment to the charted count for rank purposes: set by + * {@code /twadmin rank}, positive to promote, negative to demote. 0 for + * everyone the admins have left alone. + */ + private int chartedBonus; + /** * Whether the one-time starter kit (boat + trading bundle) has been given. */ @@ -164,6 +171,17 @@ public static String chartKey(IslandSpec spec) { return spec.cellX() + "," + spec.cellZ(); } + /** + * Charted islands as the rank ladder sees them: the real chart plus the + * admin adjustment, never below zero. Rank, the claim gate and the + * leaderboard all read this one number, so an admin promotion (or + * demotion) is consistent everywhere - and real charting keeps counting + * on top of it. + */ + public int effectiveCharted() { + return Math.max(0, chartedIslands.size() + chartedBonus); + } + /** * @return true if the island is on this player's chart */ @@ -237,6 +255,14 @@ public void setChartedIslands(Set chartedIslands) { this.chartedIslands = chartedIslands; } + public int getChartedBonus() { + return chartedBonus; + } + + public void setChartedBonus(int chartedBonus) { + this.chartedBonus = chartedBonus; + } + public String getLastSeaPosition() { return lastSeaPosition; } diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 6163848..4d9e656 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -107,6 +107,9 @@ public int buyToInventory(Player player, IslandSpec spec, Material material, int double total = PriceModel.round2(affordable * unitPrice.get()); vault.get().withdraw(user, total); ItemStack stores = new ItemStack(material, affordable); + if (material == Material.COMPASS) { + bindShipsCompass(player, stores); + } player.getInventory().addItem(stores).values() .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); user.sendMessage("tradewinds.trade.bought", "[amount]", String.valueOf(affordable), "[material]", @@ -115,6 +118,30 @@ public int buyToInventory(Player player, IslandSpec spec, Material material, int return affordable; } + /** + * The ship's compass (Stage 7b): a compass bought at the outfitter by an + * island member leaves the counter bound to their island - it points + * home from anywhere, no lodestone block required (the binding is + * untracked, so nothing has to exist at the target). Physical, lossable, + * giftable to a crewmate. Without an island it stays a plain compass - + * which points at world spawn, the spawn port, so it is never useless. + */ + private void bindShipsCompass(Player player, ItemStack compass) { + world.bentobox.tradewinds.travel.HomePort.islandOf(addon, player.getUniqueId()).ifPresent(island -> { + if (!(compass.getItemMeta() instanceof org.bukkit.inventory.meta.CompassMeta meta)) { + return; + } + org.bukkit.Location home = island.getCenter().clone(); + meta.setLodestone(home); + meta.setLodestoneTracked(false); + User console = User.getInstance(org.bukkit.Bukkit.getConsoleSender()); + meta.displayName(console.getTranslationAsComponent("tradewinds.item.ships-compass", new String[0])); + meta.lore(java.util.List.of(console.getTranslationAsComponent( + "tradewinds.item.ships-compass-lore", new String[0]))); + compass.setItemMeta(meta); + }); + } + /** * The configured price model. */ @@ -377,11 +404,14 @@ public boolean boatIsHere(Player player, IslandSpec spec) { player.getLocation().getBlockZ()) <= (long) range * range; } } - // Otherwise: wherever we last saw it - if (hold.get().getWorld() == null || hold.get().getWorld().isEmpty()) { - return false; - } - return spec.distanceSquared(hold.get().getX(), hold.get().getZ()) <= (long) range * range; + // Otherwise it must be PLACED here: found by identity among loaded + // entities and judged on its true position - the remembered position + // goes stale the moment a dismounted boat drifts, and trusting it let + // the yard sell refits to hulls it could not reach (2026-08-04) + return addon.getBoatService().findPlaced(hold.get()) + .map(placed -> spec.distanceSquared(placed.getLocation().getBlockX(), + placed.getLocation().getBlockZ()) <= (long) range * range) + .orElse(false); } /** @@ -640,16 +670,7 @@ public boolean buyBoat(Player player, IslandSpec spec, BoatRanks.Rank rank) { return false; } double price = addon.getBoatRanks().price(rank); - // A refit is a trade-in: the yard needs the old hull in front of it. - // (Buying your FIRST boat has nothing to trade in, and must always be - // possible - it is how a sailor who lost their ship gets off the - // island.) var owned = addon.getHoldService().active(player.getUniqueId()); - if (owned.isPresent() && !boatIsHere(player, spec)) { - user.sendMessage("tradewinds.trade.refit-needs-ship"); - thud(player); - return false; - } if (!vault.get().has(user, price)) { user.sendMessage("tradewinds.trade.cannot-afford"); thud(player); @@ -660,15 +681,55 @@ public boolean buyBoat(Player player, IslandSpec spec, BoatRanks.Rank rank) { // No ship at all: they are buying one outright, hull in hand var fresh = addon.getBoatService().createFor(player, rank.material()); addon.getBoatService().giveBoatItem(player, fresh); - } else { + user.sendMessage("tradewinds.trade.boat-bought-first", "[material]", pretty(rank.material()), + "[slots]", String.valueOf(rank.slots()), "[price]", Money.format(addon, price)); + } else if (isTradeIn(player, spec, rank)) { + // The ship is at the quay and the new hull is bigger: a trade-in + // - same record, cargo stays, old hull broken up addon.getBoatService().refit(player, owned.get(), rank.material()); + user.sendMessage("tradewinds.trade.boat-bought", "[material]", pretty(rank.material()), + "[slots]", String.valueOf(rank.slots()), "[price]", Money.format(addon, price)); + } else { + // Bought outright (ruled 2026-08-05): the yard ALWAYS sells - a + // sailor whose ship is an ocean away, or who wants a smaller + // hull, walks out with a new boat and their old one is left + // unowned wherever it lies, cargo aboard, first come first + // served. The dialog confirmed this before the money moved. + var old = owned.get(); + String oldName = Material.matchMaterial(old.getMaterial()) == null ? old.getMaterial() + : pretty(Material.matchMaterial(old.getMaterial())); + var fresh = addon.getBoatService().createFor(player, rank.material()); + addon.getBoatService().giveBoatItem(player, fresh); + // The plate on the abandoned hull flips to UNOWNED, if it is loaded + addon.getBoatService().relabel(old); + user.sendMessage("tradewinds.trade.boat-replaced", "[material]", pretty(rank.material()), + "[slots]", String.valueOf(rank.slots()), "[price]", Money.format(addon, price), + "[old]", oldName); } - user.sendMessage("tradewinds.trade.boat-bought", "[material]", pretty(rank.material()), - "[slots]", String.valueOf(rank.slots()), "[price]", Money.format(addon, price)); chime(player); return true; } + /** + * Whether buying this rank would be a trade-in refit: the current ship is + * at THIS island and the new hull is bigger. Everything else is an + * outright purchase that abandons the current boat where it lies. + */ + public boolean isTradeIn(Player player, IslandSpec spec, BoatRanks.Rank rank) { + Material current = addon.getHoldService().boat(player); + return current != null && boatIsHere(player, spec) + && rank.slots() > addon.getBoatRanks().slots(current); + } + + /** + * Whether buying this rank would abandon the player's current boat - the + * case the shipwright dialog puts a confirmation in front of. + */ + public boolean wouldReplaceCurrent(Player player, IslandSpec spec, BoatRanks.Rank rank) { + return addon.getHoldService().active(player.getUniqueId()).isPresent() + && !isTradeIn(player, spec, rank); + } + /** * Buy and install a cargo expander: the endgame money sink. Sold only at * top-tech ports (the sink has a home port), installs only into a Pale diff --git a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java index 9951fc7..76937b5 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TradeDialog.java @@ -379,7 +379,12 @@ public void openOutfitter(Player player, IslandSpec spec) { Money.format(addon, unit)), each, () -> outfitThenReopen(player, spec, material, 1))); if (new org.bukkit.inventory.ItemStack(material).getMaxStackSize() > 1) { - buttons.add(button(ui(player, "market.outfit-batch", "[amount]", String.valueOf(MID_BATCH), + // The batch button names its good too: unstackable gear + // gets no batch button, which shifts the two-column + // pairing, and a bare "x16 - $7,184" then sells a mystery + // (playtest 2026-08-05: the Compass batch went orphan) + buttons.add(button(ui(player, "market.outfit-batch", "[material]", name, + "[amount]", String.valueOf(MID_BATCH), "[price]", Money.format(addon, unit * MID_BATCH)), each, () -> outfitThenReopen(player, spec, material, MID_BATCH))); } @@ -417,6 +422,17 @@ public void openShipwright(Player player, IslandSpec spec) { Money.format(addon, price)), ui(player, "market.hull-tooltip", "[slots]", String.valueOf(rank.slots())), () -> { + // Buying outright abandons the current boat where it + // lies - that gets the same confirmation a capture + // does, because it is the same decision + if (addon.getMarketService().wouldReplaceCurrent(player, spec, rank)) { + confirmBoatCapture(player, MarketService.pretty(rank.material()), + MarketService.pretty(current), () -> { + addon.getMarketService().buyBoat(player, spec, rank); + openShipwright(player, spec); + }); + return; + } addon.getMarketService().buyBoat(player, spec, rank); openShipwright(player, spec); })); @@ -432,9 +448,17 @@ public void openShipwright(Player player, IslandSpec spec) { openShipwright(player, spec); })); } + List body = new ArrayList<>(List.of(ui(player, "market.shipwright-body", NO_VARS), + statusLine(player))); if (buttons.isEmpty()) { - // Nothing on the slipway: the sailor has out-teched this port - buttons.add(button(player, "market.no-hulls", "market.no-hulls-tooltip", () -> openMain(player, spec))); + // Nothing on the slipway: the sailor has out-teched this port. + // The notice goes in the BODY - rendered as a button it + // truncates and reads as a broken shop (playtest 2026-08-05) - + // and the one button offered is somewhere actually useful + body.add(ui(player, "market.no-hulls", NO_VARS)); + body.add(ui(player, "market.no-hulls-tooltip", NO_VARS)); + buttons.add(button(player, "market.outfitter", "market.outfitter-tooltip", + () -> openOutfitter(player, spec))); } if (addon.getMarketService().isDestitute(player)) { buttons.add(button(player, "market.charity", "market.charity-tooltip", @@ -443,8 +467,7 @@ public void openShipwright(Player player, IslandSpec spec) { openShipwright(player, spec); })); } - show(player, ui(player, "market.shipwright-title", "[name]", spec.name()), - List.of(ui(player, "market.shipwright-body", NO_VARS), statusLine(player)), buttons, + show(player, ui(player, "market.shipwright-title", "[name]", spec.name()), body, buttons, backButton(player, spec), 1); } diff --git a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java index 9801599..ee760ff 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java +++ b/src/main/java/world/bentobox/tradewinds/economy/TypeEconomy.java @@ -62,7 +62,13 @@ public final class TypeEconomy { 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), - IslandType.FISHING, List.of(Material.FISHING_ROD), + // 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 + // everyone else it points at world spawn, the spawn port. The + // default spawn island is FISHING, so new sailors find one at + // their first counter. + IslandType.FISHING, List.of(Material.FISHING_ROD, Material.COMPASS), IslandType.FOREST, List.of(Material.WHITE_BED), IslandType.MINING, List.of(Material.IRON_PICKAXE), IslandType.LUXURY, List.of(Material.GOLDEN_APPLE), diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java index 03d6def..3a12e89 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterListener.java @@ -38,6 +38,18 @@ public void onEncounterDeath(EntityDeathEvent event) { if (killer == null) { return; } + // Nuisance encounters (the puffer shoal) carry no booty: spice must + // not be farmable. The tag records the encounter type; mobs tagged + // before types were recorded fall through as bootied. + String tag = event.getEntity().getPersistentDataContainer().get(EncounterService.ENCOUNTER_KEY, + PersistentDataType.STRING); + try { + if (tag != null && EncounterType.valueOf(tag).isNuisance()) { + return; + } + } catch (IllegalArgumentException e) { + // Legacy "encounter" tag: not a type name, never a nuisance + } List table = addon.getSettings().getBootyTable(); if (table.isEmpty() || Math.random() >= addon.getSettings().getBootyChance()) { return; diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java index 2f3bc40..2e992c0 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java @@ -39,13 +39,6 @@ public class EncounterService { /** PDC key marking encounter mobs (booty, cleanup). */ public static final NamespacedKey ENCOUNTER_KEY = NamespacedKey.fromString("tradewinds:encounter"); - /** - * How close before a boated crew abandons ship. Wider than the spawn - * distance, so crews disembark as soon as they sight their quarry: a mob - * riding a boat cannot reposition, and a witch lobbing potions from a - * fixed drifting platform simply throws them over the target's head. - */ - private static final double BOARDING_RANGE = 30.0; /** How far encounter mobs keep hunting. */ private static final double HUNT_RANGE = 48.0; @@ -76,9 +69,10 @@ public void stop() { /** * Keep encounters dangerous: re-assert targets (mobs lose interest, and a * freshly spawned target is forgotten within moments) and put crews over - * the side when their quarry is close - a witch or pillager riding a boat - * cannot run its attack goals at all, which made the sea witch a harmless - * ornament. + * the side when their quarry is within their type's abandon-ship range - + * the witch immediately (she cannot throw from a drifting platform, + * which made the sea witch a harmless ornament), pirates only at + * boarding distance, so their crewed boat is actually seen at sea. */ private void hunt() { if (addon.getOverWorld() == null) { @@ -96,9 +90,18 @@ private void hunt() { if (mob.getTarget() == null || mob.getTarget().isDead()) { mob.setTarget(player); } - if (mob.getVehicle() instanceof Boat - && mob.getLocation().distanceSquared(player.getLocation()) < BOARDING_RANGE * BOARDING_RANGE) { - mob.leaveVehicle(); + if (mob.getVehicle() instanceof Boat) { + // Abandon ship per type (config): pirates fight from the + // deck and only jump at boarding distance; the witch + // bails wide - her potions overshoot from a drifting + // platform. The old flat 30 was INSIDE the spawn + // distance (28), so every crew ejected on its first tick + // and no sailor ever saw a manned boat (playtest + // 2026-08-05). + double abandon = abandonRange(mob); + if (mob.getLocation().distanceSquared(player.getLocation()) < abandon * abandon) { + mob.leaveVehicle(); + } } } } @@ -178,13 +181,13 @@ public void spawn(Player player, EncounterType type) { Boat crewBoat = null; if (type.isBoated()) { crewBoat = (Boat) at.getWorld().spawnEntity(at, EntityType.OAK_BOAT); - tag(crewBoat); + tag(crewBoat, type); spawned.add(crewBoat); } for (EntityType mobType : type.getMobs()) { Entity entity = at.getWorld().spawnEntity(at, mobType); equip(entity, mobType); - tag(entity); + tag(entity, type); if (entity instanceof Mob mob) { mob.setTarget(player); mob.setRemoveWhenFarAway(true); @@ -217,7 +220,20 @@ private void equip(Entity entity, EntityType type) { } } - private void tag(Entity entity) { - entity.getPersistentDataContainer().set(ENCOUNTER_KEY, PersistentDataType.STRING, "encounter"); + private void tag(Entity entity, EncounterType type) { + entity.getPersistentDataContainer().set(ENCOUNTER_KEY, PersistentDataType.STRING, type.name()); + } + + /** + * How close a player must be before THIS mob's crew abandons ship, from + * the config by encounter type. Ranged crews fight from the deck and only + * jump at boarding distance; the witch bails wide because her potions + * overshoot from a fixed platform. Mobs tagged before the type was + * recorded (or with a type since renamed) fall back to the old wide + * abandon. + */ + double abandonRange(Entity entity) { + String type = entity.getPersistentDataContainer().get(ENCOUNTER_KEY, PersistentDataType.STRING); + return addon.getSettings().getEncounterAbandonShip().getOrDefault(type, 30); } } diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterTable.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterTable.java index f937f8a..00a7bad 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterTable.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterTable.java @@ -45,6 +45,7 @@ public static double chance(double distanceFromIsland, int islandRange, double b public static Optional pick(SecurityBand band, boolean isDay, double roll) { List candidates = java.util.Arrays.stream(EncounterType.values()) .filter(type -> band.ordinal() >= type.getMinBand().ordinal()) + .filter(type -> band.ordinal() <= type.getMaxBand().ordinal()) .filter(type -> type.getTime().matches(isDay)) .toList(); if (candidates.isEmpty()) { diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java index 38fe008..77a56d7 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterType.java @@ -15,24 +15,38 @@ */ public enum EncounterType { + /** + * A shoal of pufferfish across your course: the SAFE bands' pinch of + * spice (ruled 2026-08-06). Not hostile - a HAZARD: swim through it, or + * lean too far over the gunwale, and you are stung and poisoned. A + * nuisance encounter: no booty, so there is nothing to farm, and it + * stays out of the rough bands where it would only dilute real danger. + */ + PUFFER_SHOAL(List.of(EntityType.PUFFERFISH), Time.ANY, SecurityBand.SAFE, SecurityBand.POLICED, 2, 3, + false, Habitat.WATER, true), /** Slow swimmers that block the way ahead rather than chase - daytime nuisance. */ - GUARDIAN_PICKET(List.of(EntityType.GUARDIAN), Time.DAY, SecurityBand.POLICED, 1, 2, false, Habitat.WATER), + GUARDIAN_PICKET(List.of(EntityType.GUARDIAN), Time.DAY, SecurityBand.POLICED, SecurityBand.ANARCHIC, 1, 2, + false, Habitat.WATER, false), /** Trident throwers: the real danger to a boat, after dark. */ - DROWNED_RAIDERS(List.of(EntityType.DROWNED), Time.NIGHT, SecurityBand.POLICED, 2, 3, false, Habitat.WATER), + DROWNED_RAIDERS(List.of(EntityType.DROWNED), Time.NIGHT, SecurityBand.POLICED, SecurityBand.ANARCHIC, 2, 3, + false, Habitat.WATER, false), /** * The deep stirs: an elder guardian, far too much for a lone sailor to * fight lightly - the encounter where fleeing is the sensible answer. * (26.2's nautilus, zombie or not, is a tameable MOUNT, not a monster: it * simply swims away.) */ - DEEP_TERROR(List.of(EntityType.ELDER_GUARDIAN), Time.ANY, SecurityBand.FRONTIER, 1, 1, false, Habitat.WATER), + DEEP_TERROR(List.of(EntityType.ELDER_GUARDIAN), Time.ANY, SecurityBand.FRONTIER, SecurityBand.ANARCHIC, 1, + 1, false, Habitat.WATER, false), /** The sky is not safe either. */ - PHANTOM_FLIGHT(List.of(EntityType.PHANTOM), Time.NIGHT, SecurityBand.FRONTIER, 2, 3, false, Habitat.AIR), + PHANTOM_FLIGHT(List.of(EntityType.PHANTOM), Time.NIGHT, SecurityBand.FRONTIER, SecurityBand.ANARCHIC, 2, 3, + false, Habitat.AIR, false), /** A crewed boat of raiders: NPC piracy, and a warning of the player kind. */ - PIRATE_CREW(List.of(EntityType.PILLAGER, EntityType.PILLAGER), Time.ANY, SecurityBand.LAWLESS, 1, 1, true, - Habitat.SURFACE), + PIRATE_CREW(List.of(EntityType.PILLAGER, EntityType.PILLAGER), Time.ANY, SecurityBand.LAWLESS, + SecurityBand.ANARCHIC, 1, 1, true, Habitat.SURFACE, false), /** A potion-throwing witch adrift in her own boat. */ - SEA_WITCH(List.of(EntityType.WITCH), Time.ANY, SecurityBand.LAWLESS, 1, 1, true, Habitat.SURFACE); + SEA_WITCH(List.of(EntityType.WITCH), Time.ANY, SecurityBand.LAWLESS, SecurityBand.ANARCHIC, 1, 1, true, + Habitat.SURFACE, false); /** * Where an encounter belongs relative to the waterline. Spawning a water @@ -75,20 +89,24 @@ public boolean matches(boolean isDay) { private final List mobs; private final Time time; private final SecurityBand minBand; + private final SecurityBand maxBand; private final int min; private final int max; private final boolean boated; private final Habitat habitat; + private final boolean nuisance; - EncounterType(List mobs, Time time, SecurityBand minBand, int min, int max, boolean boated, - Habitat habitat) { + EncounterType(List mobs, Time time, SecurityBand minBand, SecurityBand maxBand, int min, + int max, boolean boated, Habitat habitat, boolean nuisance) { this.mobs = mobs; this.time = time; this.minBand = minBand; + this.maxBand = maxBand; this.min = min; this.max = max; this.boated = boated; this.habitat = habitat; + this.nuisance = nuisance; } public Habitat getHabitat() { @@ -110,6 +128,22 @@ public SecurityBand getMinBand() { return minBand; } + /** + * @return the roughest band this encounter still bothers with - a + * nuisance has no place diluting real danger + */ + public SecurityBand getMaxBand() { + return maxBand; + } + + /** + * @return true for hazards rather than hunters: allowed to be + * non-hostile, and worth no booty - nothing to farm + */ + public boolean isNuisance() { + return nuisance; + } + public int getMin() { return min; } diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/IntersticeMap.java b/src/main/java/world/bentobox/tradewinds/galaxy/IntersticeMap.java new file mode 100644 index 0000000..c0d3a5e --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/galaxy/IntersticeMap.java @@ -0,0 +1,198 @@ +package world.bentobox.tradewinds.galaxy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +/** + * The interstice's features, as pure seeded geometry: wart shoals (small + * soul-sand banks breaking the dark sea) and wither watchtowers (its only + * structure). Everything is a pure function of (seed, position) - no Bukkit + * imports, headlessly testable - mirroring how the galaxy treats wild islets + * (spec principle 5). + *

+ * Interstice-plan sources 1 and 5; the decorator draws crops, groves, quartz + * and the tower masonry on top of what this class places. + * + * @author tastybento + */ +public class IntersticeMap { + + private static final long SALT_SHOAL = 0x5B0A1L; + private static final long SALT_SHOAL_X = 0x5B0A2L; + private static final long SALT_SHOAL_Z = 0x5B0A3L; + private static final long SALT_SHOAL_SIZE = 0x5B0A4L; + private static final long SALT_GRAND = 0x5B0A5L; + private static final long SALT_GRAND_KIND = 0x5B0A6L; + private static final long SALT_TOWER = 0x70E51L; + private static final long SALT_TOWER_X = 0x70E52L; + private static final long SALT_TOWER_Z = 0x70E53L; + + /** Shoal radii roll between these fractions of the nominal radius. */ + private static final double MIN_SCALE = 0.6; + private static final double MAX_SCALE = 1.4; + /** How far a shoal's crown rises above the sea at its centre. */ + private static final int CROWN_RISE = 2; + /** How far below the sea the submerged rim starts. */ + private static final int RIM_DEPTH = 2; + + /** + * A wart shoal: a soul-sand bank in the interstice. + * + * @param centerX block x of the centre + * @param centerZ block z of the centre + * @param radius footprint radius in blocks + * @param grand true for the rare grand shoal carrying a fungus grove + * @param crimson grove kind for grand shoals: crimson or warped + */ + public record Shoal(int centerX, int centerZ, int radius, boolean grand, boolean crimson) { + public long distanceSquared(int blockX, int blockZ) { + long dx = (long) blockX - centerX; + long dz = (long) blockZ - centerZ; + return dx * dx + dz * dz; + } + } + + /** A wither watchtower's position. */ + public record Tower(int centerX, int centerZ) { + } + + private final long seed; + private final int shoalGrid; + private final double shoalChance; + private final int shoalRadius; + private final double grandShoalChance; + private final int towerGrid; + private final double towerChance; + + public IntersticeMap(long seed, int shoalGrid, double shoalChance, int shoalRadius, + double grandShoalChance, int towerGrid, double towerChance) { + this.seed = seed; + this.shoalGrid = Math.max(64, shoalGrid); + this.shoalChance = shoalChance; + this.shoalRadius = shoalRadius; + this.grandShoalChance = grandShoalChance; + this.towerGrid = Math.max(256, towerGrid); + this.towerChance = towerChance; + } + + /** + * The shoal hosted by a shoal-grid cell, if any. + */ + public Optional shoalInCell(int cellX, int cellZ) { + if (shoalChance <= 0 || shoalRadius <= 0) { + return Optional.empty(); + } + if (Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_SHOAL)) >= shoalChance) { + return Optional.empty(); + } + int jitter = shoalGrid / 4; + double jx = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_SHOAL_X)) * 2 - 1; + double jz = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_SHOAL_Z)) * 2 - 1; + int x = (int) Math.round((cellX + 0.5) * shoalGrid + jx * jitter); + int z = (int) Math.round((cellZ + 0.5) * shoalGrid + jz * jitter); + double scale = MIN_SCALE + + Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_SHOAL_SIZE)) * (MAX_SCALE - MIN_SCALE); + int radius = Math.max(4, (int) Math.round(shoalRadius * scale)); + boolean grand = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_GRAND)) < grandShoalChance; + boolean crimson = Hashing.cellHash(seed, cellX, cellZ, SALT_GRAND_KIND) % 2 == 0; + return Optional.of(new Shoal(x, z, radius, grand, crimson)); + } + + /** + * Shoals whose footprint could reach a position. + */ + public List shoalsNear(int blockX, int blockZ, int range) { + List found = new ArrayList<>(); + int reach = range + (int) Math.ceil(shoalRadius * MAX_SCALE) + shoalGrid / 4; + int minCellX = Math.floorDiv(blockX - reach, shoalGrid); + int maxCellX = Math.floorDiv(blockX + reach, shoalGrid); + int minCellZ = Math.floorDiv(blockZ - reach, shoalGrid); + int maxCellZ = Math.floorDiv(blockZ + reach, shoalGrid); + for (int cx = minCellX; cx <= maxCellX; cx++) { + for (int cz = minCellZ; cz <= maxCellZ; cz++) { + shoalInCell(cx, cz).ifPresent(found::add); + } + } + return found; + } + + /** + * The absolute floor-top Y a shoal wants at a column, if any shoal's + * footprint covers it: a low dome - crown two blocks proud of the sea, + * rim starting under it, so every shoal has plantable top and wadable + * edge. The generator takes the max of this and the natural floor. + * + * @param blockX block x + * @param blockZ block z + * @param seaLevel the interstice sea surface Y + * @return the wanted floor-top Y, or empty when no shoal reaches here + */ + public OptionalInt shoalSurfaceAt(int blockX, int blockZ, int seaLevel) { + for (Shoal shoal : shoalsNear(blockX, blockZ, 0)) { + double r = Math.sqrt(shoal.distanceSquared(blockX, blockZ)); + if (r <= shoal.radius()) { + double t = 1.0 - r / shoal.radius(); + int y = seaLevel - RIM_DEPTH + (int) Math.round(t * (CROWN_RISE + RIM_DEPTH + 1)); + return OptionalInt.of(Math.min(seaLevel + 1 + CROWN_RISE, y)); + } + } + return OptionalInt.empty(); + } + + /** + * Whether a column is clear of every feature - where a stranding may + * safely drop a sailor. Towers get a fixed margin. + * + * @param blockX block x + * @param blockZ block z + * @return true if open water as far as this map is concerned + */ + public boolean isOpenWater(int blockX, int blockZ) { + if (shoalSurfaceAt(blockX, blockZ, 0).isPresent()) { + return false; + } + return towersNear(blockX, blockZ, 12).isEmpty(); + } + + /** + * The watchtower hosted by a tower-grid cell, if any. + */ + public Optional towerInCell(int cellX, int cellZ) { + if (towerChance <= 0) { + return Optional.empty(); + } + if (Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_TOWER)) >= towerChance) { + return Optional.empty(); + } + int jitter = towerGrid / 4; + double jx = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_TOWER_X)) * 2 - 1; + double jz = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_TOWER_Z)) * 2 - 1; + int x = (int) Math.round((cellX + 0.5) * towerGrid + jx * jitter); + int z = (int) Math.round((cellZ + 0.5) * towerGrid + jz * jitter); + return Optional.of(new Tower(x, z)); + } + + /** + * Towers within range of a position. + */ + public List towersNear(int blockX, int blockZ, int range) { + List found = new ArrayList<>(); + int reach = range + towerGrid / 4; + int minCellX = Math.floorDiv(blockX - reach, towerGrid); + int maxCellX = Math.floorDiv(blockX + reach, towerGrid); + int minCellZ = Math.floorDiv(blockZ - reach, towerGrid); + int maxCellZ = Math.floorDiv(blockZ + reach, towerGrid); + for (int cx = minCellX; cx <= maxCellX; cx++) { + for (int cz = minCellZ; cz <= maxCellZ; cz++) { + towerInCell(cx, cz).filter(t -> { + long dx = (long) t.centerX() - blockX; + long dz = (long) t.centerZ() - blockZ; + return dx * dx + dz * dz <= (long) (range + 1) * (range + 1) || range <= 0; + }).ifPresent(found::add); + } + } + return found; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index 2236a6b..d1f0f0e 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -67,6 +67,8 @@ private record WorldConfig(int seaHeight, int seaFloor, Material waterBlock) { private static final int CRUST_THICKNESS = 5; /** Soil depth under a land surface before it turns to stone. */ private static final int SOIL_THICKNESS = 4; + /** Y below which the bulk rock is deepslate, as in vanilla. */ + private static final int DEEPSLATE_TOP = 0; /** Layers of rock under the interstice's bedrock lid. */ private static final int ROOF_THICKNESS = 4; @@ -147,12 +149,17 @@ public void generateNoise(@NonNull WorldInfo worldInfo, @NonNull Random random, lowest = Math.min(lowest, top); } } - Material deepBase = overworld ? Material.STONE : Material.NETHERRACK; // Stop the bulk fill short of the shallowest column so every column // still lays its own surface layers - a flat chunk (a market plaza, or // dead level sea floor) would otherwise come out as bare base rock int baseTop = Math.max(minHeight + 1, lowest - ROCK_BAND); - chunkData.setRegion(0, minHeight + 1, 0, 16, baseTop, 16, deepBase); + if (overworld) { + int slateTop = Math.clamp(DEEPSLATE_TOP, minHeight + 1, baseTop); + chunkData.setRegion(0, minHeight + 1, 0, 16, slateTop, 16, Material.DEEPSLATE); + chunkData.setRegion(0, slateTop, 0, 16, baseTop, 16, Material.STONE); + } else { + chunkData.setRegion(0, minHeight + 1, 0, 16, baseTop, 16, Material.NETHERRACK); + } for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { @@ -250,6 +257,15 @@ private int floorTopAt(WorldInfo worldInfo, WorldConfig wc, Seabed floor, Galaxy int wanted = plan.surfaceY() + 1; floorTop = plan.blend() >= 1.0 ? wanted : (int) Math.round(floorTop + (wanted - floorTop) * plan.blend()); } + if (worldInfo.getEnvironment() == Environment.NETHER) { + // Wart shoals: the interstice's only land, a low soul-sand dome + // the natural floor rises to meet (interstice plan, source 1) + var wanted = addon.getIntersticeMap(worldInfo.getSeed()).shoalSurfaceAt(worldX, worldZ, + wc.seaHeight()); + if (wanted.isPresent()) { + floorTop = Math.max(floorTop, wanted.getAsInt()); + } + } return Math.clamp(floorTop, worldInfo.getMinHeight() + 2, worldInfo.getMaxHeight() - 1); } @@ -294,7 +310,7 @@ private Material columnMaterial(WorldInfo worldInfo, int y, int floorTop, boolea return IslandPalette.plazaSurface(plan.island().type()); } if (worldInfo.getEnvironment() != Environment.NORMAL) { - return intersticeMaterial(y, floorTop, sediment); + return intersticeMaterial(y, floorTop, land, sediment); } if (land) { if (y == floorTop - 1) { @@ -303,7 +319,7 @@ private Material columnMaterial(WorldInfo worldInfo, int y, int floorTop, boolea if (y >= floorTop - SOIL_THICKNESS) { return IslandPalette.subsoil(surface); } - return Material.STONE; + return baseRock(y); } // Sediment lies in a thin layer over rock; the deep basins and rift // floors are scoured down to the rock itself @@ -311,7 +327,16 @@ private Material columnMaterial(WorldInfo worldInfo, int y, int floorTop, boolea if (fromTop < SEDIMENT_THICKNESS && depth < ROCK_DEPTH) { return sedimentMaterial(sediment, depth); } - return fromTop < ROCK_BAND ? deepRock(sediment, depth) : Material.STONE; + return fromTop < ROCK_BAND ? deepRock(sediment, depth, y) : baseRock(y); + } + + /** + * The bulk rock at a height: deepslate below Y 0, stone above, as in + * vanilla - the ore veins that decoration adds take their deepslate + * variants from the block they replace. + */ + private static Material baseRock(int y) { + return y < DEEPSLATE_TOP ? Material.DEEPSLATE : Material.STONE; } /** @@ -334,20 +359,25 @@ private static Material sedimentMaterial(double sediment, int depth) { /** * The rock under the sediment, and the floor of the deep basins and rifts. */ - private static Material deepRock(double sediment, int depth) { + private static Material deepRock(double sediment, int depth, int y) { if (depth >= ROCK_DEPTH && sediment > 0.80) { return Material.TUFF; } if (depth >= ROCK_DEPTH && sediment < 0.22) { return Material.GRAVEL; } - return Material.STONE; + return baseRock(y); } /** - * The interstice's floor: basalt and soul sand, nothing worth mining. + * The interstice's floor: basalt and soul sand, nothing worth mining - + * except where a wart shoal breaks the surface, whose land is soul sand + * all the way through its crown (wart plants only on soul sand). */ - private static Material intersticeMaterial(int y, int floorTop, double sediment) { + private static Material intersticeMaterial(int y, int floorTop, boolean land, double sediment) { + if (land && y >= floorTop - SOIL_THICKNESS) { + return Material.SOUL_SAND; + } if (y >= floorTop - SEDIMENT_THICKNESS) { return sediment < 0.5 ? Material.SOUL_SAND : Material.BASALT; } diff --git a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java index 10b0c42..79efeec 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java +++ b/src/main/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrar.java @@ -83,6 +83,7 @@ public Island register(IslandSpec spec, org.bukkit.World world) { existing.setName(spec.name()); } applyBandFlags(existing, spec); + sizeRangeBox(existing); return existing; } Island island = addon.getIslands().createIsland(center, null); @@ -93,6 +94,7 @@ public Island register(IslandSpec spec, org.bukkit.World world) { } island.setName(spec.name()); applyBandFlags(island, spec); + sizeRangeBox(island); if (island.getMetaData().isEmpty()) { island.setMetaData(new HashMap<>()); } @@ -103,6 +105,27 @@ public Island register(IslandSpec spec, org.bukkit.World world) { return island; } + /** + * Size a trading island's BentoBox range box to its protection range. + *

+ * {@code createIsland} hands out the full island-distance box (1000 -> a + * 2000-block square), which reserved the whole neighbourhood: no Stage 7 + * islet claim could fit within ~1.1km of any port. The band flag configs + * always documented themselves as applying "inside the protection range", + * so the small box is what the settings promised all along. Adopted + * islands from older sessions are shrunk and re-gridded here too. + */ + private void sizeRangeBox(Island island) { + int protection = addon.getSettings().getIslandProtectionRange(); + if (island.getRange() != protection) { + island.setRange(protection); + // Re-add: the grid keeps the box from insertion time, so without + // this the sea only frees up after a restart + addon.getIslands().getIslandCache().addIsland(island); + world.bentobox.bentobox.managers.IslandsManager.saveIsland(island); + } + } + /** * Apply the security band's configured flags to an island: PvP, hostile * spawning within the protection range, and the hurt-villagers rank diff --git a/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java index 5d315b8..82becbd 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java @@ -2,26 +2,39 @@ import java.util.Random; +import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.block.BlockState; +import org.bukkit.block.Chest; +import org.bukkit.block.CreatureSpawner; +import org.bukkit.block.data.Ageable; +import org.bukkit.entity.EntityType; import org.bukkit.generator.BlockPopulator; import org.bukkit.generator.LimitedRegion; import org.bukkit.generator.WorldInfo; +import org.bukkit.loot.LootTable; import org.eclipse.jdt.annotation.NonNull; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IntersticeMap; /** - * Braziers in the dark: netherrack outcrops rising out of the interstice sea - * with fire burning on top. - *

- * The interstice was a flat black nothing - a player could not see the water, - * the horizon, or what was coming at them, which made it read as unfinished - * rather than hostile. These are the only light out there. Fire on netherrack - * burns forever and there is nothing for it to spread to, so they stay lit. - *

- * They double as landmarks. Somewhere featureless is disorienting in a way that - * somewhere dangerous is not, and a burning rock on the horizon gives a - * castaway something to steer by while they decide what to do. + * The interstice's furniture (tradewinds-interstice-plan.md). Braziers were + * here first - the only light in the place; the plan grew them company: + *

    + *
  • Nether wart crops on the shoals, and crimson/warped groves on the + * grand ones - the potion base, farmable in the dark.
  • + *
  • Blaze pickets: some braziers carry a nether-brick crow's nest with a + * blaze spawner - rods, fought from a boat.
  • + *
  • Glowstone clusters hanging from the ceiling lid - light as navigation + * and destination at once.
  • + *
  • Quartz ore seeded through shoal cores and brazier roots.
  • + *
  • Wither watchtowers: the interstice's only structure - wither skeleton + * spawner below, loot chest above, skulls to a nether star to a beacon.
  • + *
+ * Geometry (where things are) comes from the pure {@link IntersticeMap}; + * this class only draws what the map declares. * * @author tastybento */ @@ -31,6 +44,10 @@ public class IntersticeDecorator extends BlockPopulator { private static final int MAX_RISE = 4; /** How far below the surface the outcrop is rooted. */ private static final int ROOT_DEPTH = 3; + /** Matches the generator's interstice lid thickness. */ + private static final int ROOF_THICKNESS = 4; + /** Watchtower half-width: a 5x5 tower. */ + private static final int TOWER_HALF = 2; private final TradeWinds addon; @@ -41,11 +58,21 @@ public IntersticeDecorator(TradeWinds addon) { @Override public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int chunkX, int chunkZ, @NonNull LimitedRegion region) { + int sea = addon.getSettings().getIntersticeSeaHeight(); + IntersticeMap map = addon.getIntersticeMap(worldInfo.getSeed()); + brazier(random, region, chunkX, chunkZ, sea); + shoals(map, random, region, chunkX, chunkZ, sea); + glowstoneCeiling(random, region, chunkX, chunkZ, worldInfo, sea); + watchtower(map, region, chunkX, chunkZ, sea); + } + + // ------------------------------------------------------------- braziers + + private void brazier(Random random, LimitedRegion region, int chunkX, int chunkZ, int sea) { double chance = addon.getSettings().getIntersticeBrazierChance(); if (chance <= 0 || random.nextDouble() >= chance) { return; } - int sea = addon.getSettings().getIntersticeSeaHeight(); int x = (chunkX << 4) + random.nextInt(16); int z = (chunkZ << 4) + random.nextInt(16); if (!region.isInRegion(x, sea, z) || region.getType(x, sea, z) != Material.WATER) { @@ -55,16 +82,24 @@ public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int c int top = sea + rise; // A squat outcrop: widest at the waterline, tapering as it rises, so it // reads as a rock rather than a pillar + double quartz = addon.getSettings().getIntersticeQuartzChance(); for (int y = sea - ROOT_DEPTH; y <= top; y++) { int spread = y <= sea ? 1 : 0; for (int dx = -spread; dx <= spread; dx++) { for (int dz = -spread; dz <= spread; dz++) { if (region.isInRegion(x + dx, y, z + dz)) { - region.setType(x + dx, y, z + dz, Material.NETHERRACK); + // Quartz lives in the submerged roots (plan source 4) + boolean ore = y < sea && random.nextDouble() < quartz; + region.setType(x + dx, y, z + dz, + ore ? Material.NETHER_QUARTZ_ORE : Material.NETHERRACK); } } } } + if (random.nextDouble() < addon.getSettings().getIntersticeBlazePicketChance()) { + picket(region, x, top, z); + return; + } // The flame, and a little glow under the waterline so the rock is // visible from below as well as across the water if (region.isInRegion(x, top + 1, z)) { @@ -74,4 +109,247 @@ public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int c region.setType(x, sea - ROOT_DEPTH, z, Material.GLOWSTONE); } } + + /** + * A blaze picket: a nether-brick crow's nest on the brazier with a blaze + * spawner burning where the fire would have been. + */ + private void picket(LimitedRegion region, int x, int top, int z) { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + if (region.isInRegion(x + dx, top + 1, z + dz)) { + region.setType(x + dx, top + 1, z + dz, Material.NETHER_BRICKS); + } + if (dx != 0 && dz != 0 && region.isInRegion(x + dx, top + 2, z + dz)) { + region.setType(x + dx, top + 2, z + dz, Material.NETHER_BRICK_WALL); + } + } + } + spawner(region, x, top + 2, z, EntityType.BLAZE); + } + + // --------------------------------------------------------------- shoals + + private void shoals(IntersticeMap map, Random random, LimitedRegion region, int chunkX, int chunkZ, + int sea) { + double wart = addon.getSettings().getIntersticeWartDensity(); + double quartz = addon.getSettings().getIntersticeQuartzChance(); + int minX = chunkX << 4; + int minZ = chunkZ << 4; + for (int dx = 0; dx < 16; dx++) { + for (int dz = 0; dz < 16; dz++) { + int x = minX + dx; + int z = minZ + dz; + var surface = map.shoalSurfaceAt(x, z, sea); + if (surface.isEmpty()) { + continue; + } + int floorTop = surface.getAsInt(); + // Wart on the dry soul sand (plan source 1) + if (floorTop > sea + 1 && wart > 0 && random.nextDouble() < wart + && region.isInRegion(x, floorTop, z) + && region.getType(x, floorTop - 1, z) == Material.SOUL_SAND + && region.getType(x, floorTop, z) == Material.AIR) { + Ageable crop = (Ageable) Material.NETHER_WART.createBlockData(); + crop.setAge(random.nextInt(crop.getMaximumAge() + 1)); + region.setBlockData(x, floorTop, z, crop); + } + // Quartz in the submerged core (plan source 4) + for (int y = sea - 5; y < Math.min(floorTop - 2, sea); y++) { + if (quartz > 0 && random.nextDouble() < quartz && region.isInRegion(x, y, z) + && region.getType(x, y, z) == Material.NETHERRACK) { + region.setType(x, y, z, Material.NETHER_QUARTZ_ORE); + } + } + } + } + // The grand shoal's grove, drawn once, from the chunk holding its centre + map.shoalsNear(minX + 8, minZ + 8, 8).stream() + .filter(IntersticeMap.Shoal::grand) + .filter(s -> s.centerX() >= minX && s.centerX() < minX + 16 + && s.centerZ() >= minZ && s.centerZ() < minZ + 16) + .forEach(s -> grove(map, random, region, s, sea)); + } + + /** + * A grand shoal's fungus grove: a few hand-rolled crimson or warped + * "trees" - stem, wart-block cap, shroomlights - the interstice's only + * wood. + */ + private void grove(IntersticeMap map, Random random, LimitedRegion region, IntersticeMap.Shoal shoal, + int sea) { + Material stem = shoal.crimson() ? Material.CRIMSON_STEM : Material.WARPED_STEM; + Material cap = shoal.crimson() ? Material.NETHER_WART_BLOCK : Material.WARPED_WART_BLOCK; + int trees = 2 + random.nextInt(3); + for (int i = 0; i < trees; i++) { + int fx = shoal.centerX() + random.nextInt(shoal.radius()) - shoal.radius() / 2; + int fz = shoal.centerZ() + random.nextInt(shoal.radius()) - shoal.radius() / 2; + var ground = map.shoalSurfaceAt(fx, fz, sea); + if (ground.isEmpty() || ground.getAsInt() <= sea + 1) { + continue; // roots want dry land + } + int base = ground.getAsInt(); + int height = 4 + random.nextInt(3); + for (int y = 0; y < height; y++) { + if (region.isInRegion(fx, base + y, fz)) { + region.setType(fx, base + y, fz, stem); + } + } + int capY = base + height; + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + if (region.isInRegion(fx + dx, capY, fz + dz)) { + boolean light = dx != 0 && dz != 0 && random.nextInt(3) == 0; + region.setType(fx + dx, capY, fz + dz, light ? Material.SHROOMLIGHT : cap); + } + } + } + if (region.isInRegion(fx, capY + 1, fz)) { + region.setType(fx, capY + 1, fz, cap); + } + } + } + + // -------------------------------------------------------------- ceiling + + private void glowstoneCeiling(Random random, LimitedRegion region, int chunkX, int chunkZ, + WorldInfo worldInfo, int sea) { + double chance = addon.getSettings().getIntersticeGlowstoneClusterChance(); + int ceiling = addon.getSettings().getIntersticeCeilingHeight(); + if (chance <= 0 || ceiling <= 0 || random.nextDouble() >= chance) { + return; + } + // The lid's underside, as the generator builds it + int roof = Math.min(worldInfo.getMaxHeight() - 1, sea + ceiling); + int underside = Math.max(sea + 1, roof - ROOF_THICKNESS); + int x = (chunkX << 4) + 2 + random.nextInt(12); + int z = (chunkZ << 4) + 2 + random.nextInt(12); + // A hanging blob: a patch in the lid itself and a drip below it + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + if ((dx == 0 && dz == 0 || random.nextBoolean()) + && region.isInRegion(x + dx, underside, z + dz)) { + region.setType(x + dx, underside, z + dz, Material.GLOWSTONE); + } + } + } + if (region.isInRegion(x, underside - 1, z)) { + region.setType(x, underside - 1, z, Material.GLOWSTONE); + } + if (random.nextBoolean() && region.isInRegion(x, underside - 2, z)) { + region.setType(x, underside - 2, z, Material.GLOWSTONE); + } + } + + // ---------------------------------------------------------- watchtowers + + private void watchtower(IntersticeMap map, LimitedRegion region, int chunkX, int chunkZ, int sea) { + int minX = chunkX << 4; + int minZ = chunkZ << 4; + map.towersNear(minX + 8, minZ + 8, 16).stream() + .filter(t -> t.centerX() >= minX && t.centerX() < minX + 16 + && t.centerZ() >= minZ && t.centerZ() < minZ + 16) + .forEach(t -> buildTower(region, t.centerX(), t.centerZ(), sea)); + } + + /** + * The wither watchtower: solid nether-brick footing out of the sea, a + * spawner room with a doorway at the waterline, a sealed loot floor + * above, open crenellations on top. Skulls below, treasure above, and + * every fight happens over open water. + */ + private void buildTower(LimitedRegion region, int cx, int cz, int sea) { + // Footing: solid to two above the waterline + fill(region, cx, cz, sea - 16, sea + 2, Material.NETHER_BRICKS, false); + // Spawner room, then the loot floor over it + fill(region, cx, cz, sea + 3, sea + 7, Material.NETHER_BRICKS, true); + fill(region, cx, cz, sea + 8, sea + 8, Material.NETHER_BRICKS, false); + fill(region, cx, cz, sea + 9, sea + 11, Material.NETHER_BRICKS, true); + // Doorway to the spawner room, at boat height, facing north + for (int y = sea + 3; y <= sea + 4; y++) { + if (region.isInRegion(cx, y, cz - TOWER_HALF)) { + region.setType(cx, y, cz - TOWER_HALF, Material.AIR); + } + } + // Window slits on the loot floor + for (int y = sea + 9; y <= sea + 10; y++) { + if (region.isInRegion(cx + TOWER_HALF, y, cz)) { + region.setType(cx + TOWER_HALF, y, cz, Material.AIR); + } + if (region.isInRegion(cx - TOWER_HALF, y, cz)) { + region.setType(cx - TOWER_HALF, y, cz, Material.AIR); + } + } + // Crenellated open top + for (int dx = -TOWER_HALF; dx <= TOWER_HALF; dx++) { + for (int dz = -TOWER_HALF; dz <= TOWER_HALF; dz++) { + boolean wall = Math.abs(dx) == TOWER_HALF || Math.abs(dz) == TOWER_HALF; + boolean corner = Math.abs(dx) == TOWER_HALF && Math.abs(dz) == TOWER_HALF; + if (wall && region.isInRegion(cx + dx, sea + 12, cz + dz)) { + region.setType(cx + dx, sea + 12, cz + dz, + corner ? Material.NETHER_BRICKS : Material.NETHER_BRICK_WALL); + } + } + } + // The tenant and the prize + spawner(region, cx, sea + 4, cz, EntityType.WITHER_SKELETON); + chest(region, cx, sea + 9, cz); + // A soul-sand pile in the spawner room: the summoning is meant to be + // POSSIBLE here, not convenient - skulls still have to be earned + if (region.isInRegion(cx + 1, sea + 3, cz + 1)) { + region.setType(cx + 1, sea + 3, cz + 1, Material.SOUL_SAND); + } + if (region.isInRegion(cx - 1, sea + 3, cz + 1)) { + region.setType(cx - 1, sea + 3, cz + 1, Material.SOUL_SAND); + } + } + + /** A 5x5 slab or shell between two heights, inclusive. */ + private void fill(LimitedRegion region, int cx, int cz, int fromY, int toY, Material material, + boolean hollow) { + for (int y = fromY; y <= toY; y++) { + for (int dx = -TOWER_HALF; dx <= TOWER_HALF; dx++) { + for (int dz = -TOWER_HALF; dz <= TOWER_HALF; dz++) { + boolean wall = Math.abs(dx) == TOWER_HALF || Math.abs(dz) == TOWER_HALF; + if (!region.isInRegion(cx + dx, y, cz + dz)) { + continue; + } + region.setType(cx + dx, y, cz + dz, !hollow || wall ? material : Material.AIR); + } + } + } + } + + // ---------------------------------------------------------------- tiles + + private void spawner(LimitedRegion region, int x, int y, int z, EntityType type) { + if (!region.isInRegion(x, y, z)) { + return; + } + region.setType(x, y, z, Material.SPAWNER); + BlockState state = region.getBlockState(x, y, z); + if (state instanceof CreatureSpawner cs) { + cs.setSpawnedType(type); + cs.update(); + } + } + + private void chest(LimitedRegion region, int x, int y, int z) { + if (!region.isInRegion(x, y, z)) { + return; + } + region.setType(x, y, z, Material.CHEST); + NamespacedKey key = NamespacedKey.fromString(addon.getSettings().getIntersticeWatchtowerLootTable()); + LootTable loot = key == null ? null : Bukkit.getLootTable(key); + if (loot == null) { + addon.logError("interstice.watchtower-loot-table '" + + addon.getSettings().getIntersticeWatchtowerLootTable() + "' is not a loot table"); + return; + } + BlockState state = region.getBlockState(x, y, z); + if (state instanceof Chest chestState) { + chestState.setLootTable(loot); + chestState.update(); + } + } } diff --git a/src/main/java/world/bentobox/tradewinds/listeners/IntersticePortalListener.java b/src/main/java/world/bentobox/tradewinds/listeners/IntersticePortalListener.java index e70c230..cbe4f31 100644 --- a/src/main/java/world/bentobox/tradewinds/listeners/IntersticePortalListener.java +++ b/src/main/java/world/bentobox/tradewinds/listeners/IntersticePortalListener.java @@ -33,12 +33,21 @@ private boolean inTradeWindsWorld(World world) { } /** - * No portal blocks ever form (lighting an obsidian frame does nothing). + * No portal blocks ever form - but the refusal SPEAKS. Ruined portals + * are common ocean structures, so players complete and light them, and + * a silent non-answer reads as a bug; the message makes it a world rule + * (interstice plan, 2026-08-05). */ @EventHandler(priority = EventPriority.LOWEST) public void onPortalCreate(PortalCreateEvent event) { - if (inTradeWindsWorld(event.getWorld())) { - event.setCancelled(true); + if (!inTradeWindsWorld(event.getWorld())) { + return; + } + event.setCancelled(true); + if (event.getReason() == PortalCreateEvent.CreateReason.FIRE + && event.getEntity() instanceof org.bukkit.entity.Player player) { + world.bentobox.bentobox.api.user.User.getInstance(player) + .sendMessage("tradewinds.interstice.no-portal"); } } diff --git a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java index 3219729..a8a4481 100644 --- a/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java +++ b/src/main/java/world/bentobox/tradewinds/listeners/SpawnRespawnListener.java @@ -39,7 +39,13 @@ public void onRespawn(PlayerRespawnEvent event) { if (!world.equals(addon.getOverWorld()) && !world.equals(addon.getNetherWorld())) { return; } - grantRespawnBoat(event); + // The loaner is granted a tick AFTER the respawn: items handed out + // during PlayerRespawnEvent are wiped when the respawn restores the + // inventory, which is why no raft ever arrived (playtest 2026-08-05). + // A tick later also means the player's REAL position judges whether + // their own boat is within reach - island respawners included. + org.bukkit.Bukkit.getScheduler().runTask(addon.getPlugin(), + () -> grantRespawnBoat(event.getPlayer())); if (event.isBedSpawn() || event.isAnchorSpawn()) { return; // they made a home somewhere - honor it } @@ -47,6 +53,16 @@ public void onRespawn(PlayerRespawnEvent event) { if (overworld == null) { return; } + // Island members respawn at their claim: BentoBox's ISLAND_RESPAWN + // listener already set the location (it runs at NORMAL, this handler + // at HIGH) - overwriting it here sent island owners to the spawn + // port (playtest 2026-08-05). This listener predates player islands: + // its job is only the ISLANDLESS, who would otherwise respawn in the + // seabed. + var island = addon.getIslands().getIsland(overworld, event.getPlayer().getUniqueId()); + if (island != null && island.getMemberSet().contains(event.getPlayer().getUniqueId())) { + return; + } // The spawn island's own spawn point - the market plaza, and wherever // an admin has since moved it. The island CENTRE is wooded ground and // must not be used: players were respawning in the treetops. @@ -57,39 +73,43 @@ public void onRespawn(PlayerRespawnEvent event) { /** * The port's loaner: a boatless respawner gets the configured hull (their * own boat is floating back where they died, and rowing out to reclaim it - * is the intended recovery trip). + * is the intended recovery trip). Runs a tick after the respawn, on the + * player's real post-respawn state. */ - private void grantRespawnBoat(PlayerRespawnEvent event) { + private void grantRespawnBoat(org.bukkit.entity.Player player) { + if (!player.isOnline()) { + return; + } Material boat = respawnBoat(); - if (boat == null || boatWithinReach(event)) { + if (boat == null || boatWithinReach(player)) { return; } // They have no boat, or theirs is far away: lend a hull. If they had // one, boarding this raft is what abandons it (with the standard // confirmation) - the loaner itself takes nothing from them. - var hold = addon.getHoldService().active(event.getPlayer().getUniqueId()).isEmpty() - ? addon.getBoatService().createFor(event.getPlayer(), boat) + var hold = addon.getHoldService().active(player.getUniqueId()).isEmpty() + ? addon.getBoatService().createFor(player, boat) : addon.getHoldManager().create(boat, null); - addon.getBoatService().giveBoatItem(event.getPlayer(), hold); - User.getInstance(event.getPlayer()).sendMessage("tradewinds.boat.respawn-given", "[material]", + addon.getBoatService().giveBoatItem(player, hold); + User.getInstance(player).sendMessage("tradewinds.boat.respawn-given", "[material]", world.bentobox.tradewinds.economy.PriceEngine.prettify(boat.name())); } /** - * Whether their own boat is close enough to the respawn point to walk to - * - otherwise a sailor is stranded ashore with a ship an ocean away. + * Whether their own boat is close enough to where they now stand to walk + * to - otherwise a sailor is stranded ashore with a ship an ocean away. */ - private boolean boatWithinReach(PlayerRespawnEvent event) { - var hold = addon.getHoldService().active(event.getPlayer().getUniqueId()); + private boolean boatWithinReach(org.bukkit.entity.Player player) { + var hold = addon.getHoldService().active(player.getUniqueId()); if (hold.isEmpty() || hold.get().getWorld() == null || hold.get().getWorld().isEmpty()) { return false; } - Location respawn = event.getRespawnLocation(); - if (respawn.getWorld() == null || !respawn.getWorld().getName().equals(hold.get().getWorld())) { + Location at = player.getLocation(); + if (at.getWorld() == null || !at.getWorld().getName().equals(hold.get().getWorld())) { return false; } int reach = addon.getSettings().getIslandProtectionRange(); - return respawn.distanceSquared(new Location(respawn.getWorld(), hold.get().getX(), hold.get().getY(), + return at.distanceSquared(new Location(at.getWorld(), hold.get().getX(), hold.get().getY(), hold.get().getZ())) <= (double) reach * reach; } diff --git a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java index 5f4da75..9106f74 100644 --- a/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java +++ b/src/main/java/world/bentobox/tradewinds/tasks/NavigationBarTask.java @@ -35,6 +35,8 @@ public class NavigationBarTask implements Runnable { private final TradeWinds addon; private final Map bars = new ConcurrentHashMap<>(); + /** Players with the course-home bar on (/tw go at sea toggles it). */ + private final java.util.Set course = ConcurrentHashMap.newKeySet(); private BukkitTask task; public NavigationBarTask(TradeWinds addon) { @@ -71,12 +73,78 @@ public void run() { GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); Optional reading = reading(engine, player.getLocation().getBlockX(), player.getLocation().getBlockZ(), addon.getSettings().getIslandDistance()); - if (reading.isEmpty()) { - hide(player); + if (reading.isPresent()) { + show(player, reading.get()); + continue; + } + if (course.contains(player.getUniqueId()) && showCourse(player)) { continue; } - show(player, reading.get()); + hide(player); + } + } + + /** + * Toggle the course-home bar for a player. + * + * @param player the player + * @return true if the course is now ON + */ + public boolean toggleCourse(Player player) { + if (!course.remove(player.getUniqueId())) { + course.add(player.getUniqueId()); + return true; + } + hide(player); + return false; + } + + /** + * The course-home bar: read the sky, follow the number (Stage 7b). By + * day the sun gives only the QUARTER the wind should sit on - an + * eight-point direction; by night the stars give the exact distance too, + * so waiting for dark is rewarded, never required. Arriving in your own + * island's waters clears the course by itself. + * + * @return true if a course bar was shown + */ + private boolean showCourse(Player player) { + var island = world.bentobox.tradewinds.travel.HomePort.islandOf(addon, player.getUniqueId()) + .orElse(null); + if (island == null) { + course.remove(player.getUniqueId()); + return false; } + int px = player.getLocation().getBlockX(); + int pz = player.getLocation().getBlockZ(); + int dx = island.getCenter().getBlockX() - px; + int dz = island.getCenter().getBlockZ() - pz; + double dist = Math.hypot(dx, dz); + if (dist <= island.getProtectionRange()) { + // Landfall: the course has done its job + course.remove(player.getUniqueId()); + User.getInstance(player).sendMessage("tradewinds.home.course-arrived"); + return false; + } + User user = User.getInstance(player); + long time = player.getWorld().getTime(); + boolean night = time >= 12542 && time <= 23460; + String direction = user.getTranslation( + world.bentobox.tradewinds.commands.TWSpawnCommand.compassKey(dx, dz)); + Component name = night + ? user.getTranslationAsComponent("tradewinds.hud.course-night", "[direction]", direction, + "[distance]", String.valueOf(Math.round(dist))) + : user.getTranslationAsComponent("tradewinds.hud.course-day", "[direction]", direction); + float progress = night + ? (float) Math.clamp(1.0 - dist / addon.getSettings().getCourseBarScale(), 0.0, 1.0) + : 1.0f; + BossBar bar = bars.computeIfAbsent(player.getUniqueId(), + id -> BossBar.bossBar(name, progress, BossBar.Color.WHITE, BossBar.Overlay.PROGRESS)); + bar.name(name); + bar.progress(progress); + bar.color(night ? BossBar.Color.BLUE : BossBar.Color.WHITE); + player.showBossBar(bar); + return true; } /** diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatRanks.java b/src/main/java/world/bentobox/tradewinds/travel/BoatRanks.java index dcdcfaa..10abe31 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatRanks.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatRanks.java @@ -94,8 +94,13 @@ public double price(Rank rank) { public List shopListing(Material current, int techLevel) { int currentSlots = slots(current); int maxRank = techLevel * addon.getSettings().getBoatRanksPerTechLevel(); + // Every rung the tech can build EXCEPT the one you already sail + // (ruled 2026-08-05): a yard always sells - a bigger hull with your + // ship at the quay is a trade-in, anything else is bought outright + // and your old boat is left unowned where it lies. Upgrade-only + // listings stranded sailors whose ship outranked the local tech. return ladder().stream() - .filter(r -> r.slots() > currentSlots) + .filter(r -> r.slots() != currentSlots) .filter(r -> r.rank() <= maxRank) .toList(); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java index 0ca88d0..c59795b 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java @@ -169,25 +169,49 @@ public void label(Entity entity, BoatHold hold) { } /** - * Re-read the label of whatever avatar this record has in the world, if - * it is loaded. + * The record's placed avatar in the world, if any is loaded: the Boat + * entity it rides as, or the dropped item that carries its stamp. + *

+ * Found by IDENTITY across the world's loaded entities, never by a box + * around the last-remembered position - a dismounted boat glides and + * drifts, so position boxes miss and then the caller mutates a record + * whose hull it never touched (the moored-refit bug, 2026-08-04: the + * yard sold a spruce hull and the oak boat kept floating at the dock). + * A hit also re-remembers the position, so the chart heals as a side + * effect of anyone looking. + * + * @param hold the boat + * @return its placed avatar, or empty if none is loaded */ - public void relabel(BoatHold hold) { + public Optional findPlaced(BoatHold hold) { if (hold.getWorld() == null || hold.getWorld().isEmpty()) { - return; + return Optional.empty(); } org.bukkit.World world = Bukkit.getWorld(hold.getWorld()); if (world == null) { - return; + return Optional.empty(); } - Location where = new Location(world, hold.getX(), hold.getY(), hold.getZ()); - if (!world.isChunkLoaded(where.getBlockX() >> 4, where.getBlockZ() >> 4)) { - return; + for (Boat boat : world.getEntitiesByClass(Boat.class)) { + if (hold.getUniqueId().equals(boatId(boat))) { + addon.getHoldManager().rememberPosition(hold, boat.getLocation()); + return Optional.of(boat); + } } - world.getNearbyEntities(where, 4, 4, 4).stream() - .filter(Boat.class::isInstance) - .filter(e -> hold.getUniqueId().equals(boatId(e))) - .forEach(e -> label(e, hold)); + for (org.bukkit.entity.Item item : world.getEntitiesByClass(org.bukkit.entity.Item.class)) { + if (hold.getUniqueId().equals(boatId(item.getItemStack()))) { + addon.getHoldManager().rememberPosition(hold, item.getLocation()); + return Optional.of(item); + } + } + return Optional.empty(); + } + + /** + * Re-read the label of whatever avatar this record has in the world, if + * it is loaded. + */ + public void relabel(BoatHold hold) { + findPlaced(hold).ifPresent(e -> label(e, hold)); } // ------------------------------------------------------------- ownership @@ -237,25 +261,30 @@ public void refit(Player player, BoatHold hold, Material material) { return; } } - // Moored alongside: replace the hull where it floats - org.bukkit.World world = Bukkit.getWorld(hold.getWorld()); - if (world != null && world.isChunkLoaded(hold.getX() >> 4, hold.getZ() >> 4)) { - Location where = new Location(world, hold.getX(), hold.getY(), hold.getZ()); - world.getNearbyEntities(where, 6, 6, 6).stream().filter(Boat.class::isInstance) - .filter(e -> hold.getUniqueId().equals(boatId(e))).findFirst().ifPresent(old -> { - Location at = old.getLocation(); - old.remove(); - Material type = Material.matchMaterial(hold.getMaterial()); - org.bukkit.entity.EntityType entityType; - try { - entityType = org.bukkit.entity.EntityType - .valueOf(type == null ? "OAK_BOAT" : type.name()); - } catch (IllegalArgumentException e) { - entityType = org.bukkit.entity.EntityType.OAK_BOAT; - } - stamp(at.getWorld().spawnEntity(at, entityType), hold); - }); - } + // Moored alongside (or lying about as a dropped item): swap the hull + // where it actually is, found by identity - never by where the chart + // last saw it, because dismounted boats glide and drift + findPlaced(hold).ifPresentOrElse(placed -> { + if (placed instanceof Boat old) { + Location at = old.getLocation(); + old.remove(); + org.bukkit.entity.EntityType entityType; + try { + entityType = org.bukkit.entity.EntityType.valueOf(material.name()); + } catch (IllegalArgumentException e) { + entityType = org.bukkit.entity.EntityType.OAK_BOAT; + } + stamp(at.getWorld().spawnEntity(at, entityType), hold); + addon.getHoldManager().rememberPosition(hold, at); + } else if (placed instanceof org.bukkit.entity.Item item) { + ItemStack stack = item.getItemStack(); + stack.setType(material); + stamp(stack, hold); + item.setItemStack(stack); + } + }, () -> addon.logWarning("Refit paid for boat " + hold.getUniqueId() + + " but no hull was loaded to swap - record now says " + hold.getMaterial() + + " over the old hull. Should be impossible: the purchase gate saw the hull moments ago.")); } /** diff --git a/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java b/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java index 42d3e8b..e1ba86f 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BorderPromptListener.java @@ -76,7 +76,11 @@ private void check(Player player, int x, int z) { if (!player.getWorld().equals(addon.getOverWorld())) { return; } - Optional origin = originIslandNearBorder(x, z); + Optional origin = originIslandNearBorder(x, z) + // A member's claimed islet is a warp node too (Stage 7b): + // its protection border offers the dialog exactly like a + // port's - but only to its own members + .or(() -> homeBorder(player, x, z)); if (origin.isEmpty()) { return; } @@ -103,6 +107,24 @@ private void check(Player player, int x, int z) { * trigger-distance either side of the line so an outbound crossing cannot * skip it. */ + /** + * The player's own claimed island, as a warp origin, if this position is + * at its protection border. The ring test mirrors the trading islands', + * with the claim's own (smaller) protection range. + */ + private Optional homeBorder(Player player, int x, int z) { + return HomePort.specFor(addon, world.bentobox.bentobox.api.user.User.getInstance(player)) + .filter(home -> { + int border = HomePort.islandOf(addon, player.getUniqueId()) + .map(world.bentobox.bentobox.database.objects.Island::getProtectionRange) + .orElse(0); + int trigger = addon.getSettings().getWarpTriggerDistance(); + long d2 = home.distanceSquared(x, z); + return border > 0 && d2 <= (long) (border + trigger) * (border + trigger) + && d2 >= (long) (border - trigger) * (border - trigger); + }); + } + Optional originIslandNearBorder(int x, int z) { GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); int border = addon.getSettings().getIslandProtectionRange(); diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java index b6a311c..56b1478 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartHolograms.java @@ -212,10 +212,42 @@ public void show(Player player) { .filter(hold -> worthMarking(player, hold, eye)) .ifPresent(hold -> boats.add(boatMarker(hold.getX(), hold.getZ(), eye.getBlockX(), eye.getBlockZ(), radius, true))); - if (markers.isEmpty() && boats.isEmpty()) { + // HOME: a member's claimed islet rides on every raise of the chart - + // above the boat markers, because home is the longest bearing + BoatMarker home = HomePort.islandOf(addon, player.getUniqueId()) + .map(island -> { + BoatMarker m = boatMarker(island.getCenter().getBlockX(), island.getCenter().getBlockZ(), + eye.getBlockX(), eye.getBlockZ(), radius, false); + return new BoatMarker(m.dx(), m.dy() + 1.2, m.dz(), m.distance(), false); + }) + .filter(m -> m.distance() > MARKER_MIN_DISTANCE) + .orElse(null); + if (markers.isEmpty() && boats.isEmpty() && home == null) { return; } List spawned = new ArrayList<>(); + if (home != null) { + TextDisplay display = eye.getWorld().spawn(eye.clone().add(0, 1.0, 0), TextDisplay.class); + display.text(User.getInstance(player).getTranslationAsComponent("tradewinds.hologram.home", + "[distance]", String.valueOf(home.distance()))); + display.setBillboard(Billboard.CENTER); + display.setSeeThrough(true); + display.setBackgroundColor(Color.fromARGB(140, 60, 45, 0)); + display.setPersistent(false); + display.setTeleportDuration(ZOOM_TICKS); + for (Player other : Bukkit.getOnlinePlayers()) { + if (!other.equals(player)) { + other.hideEntity(addon.getPlugin(), display); + } + } + spawned.add(display); + Location target = eye.clone().add(home.dx(), home.dy(), home.dz()); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { + if (display.isValid()) { + display.teleport(target); + } + }); + } for (BoatMarker boat : boats) { TextDisplay display = eye.getWorld().spawn(eye.clone().add(0, 1.0, 0), TextDisplay.class); display.text(User.getInstance(player).getTranslationAsComponent( diff --git a/src/main/java/world/bentobox/tradewinds/travel/ChartLeaderboard.java b/src/main/java/world/bentobox/tradewinds/travel/ChartLeaderboard.java new file mode 100644 index 0000000..b6679dc --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/ChartLeaderboard.java @@ -0,0 +1,123 @@ +package world.bentobox.tradewinds.travel; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.IslandChartedEvent; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; + +/** + * The charted-islands leaderboard: who has seen the most of the sea. This is + * the game's ranking - money cannot be ranked (Vault offers no top-N, and Bank + * moves value out of sight), but charted count is earned, stored per player, + * and drives the Seafarer ranks. + *

+ * Counts are cached: seeded from the database off-thread at startup (sailors + * keep their standing while offline), then kept fresh by + * {@link IslandChartedEvent} - both the sighting and the port-scan path fire + * it per newly charted island. + * + * @author tastybento + */ +public class ChartLeaderboard implements Listener { + + /** One leaderboard row. */ + public record Entry(UUID playerId, int charted) { + } + + private final TradeWinds addon; + private final Map counts = new ConcurrentHashMap<>(); + + public ChartLeaderboard(TradeWinds addon) { + this.addon = addon; + } + + /** + * Seed the cache from the database, off the main thread - server start is + * busy enough, and the leaderboard can be seconds stale for its first + * moments without anyone noticing. + */ + public void seed() { + Bukkit.getScheduler().runTaskAsynchronously(addon.getPlugin(), () -> { + var handler = new world.bentobox.bentobox.database.Database<>(addon, TWPlayerData.class); + handler.loadObjects().forEach(data -> { + try { + counts.put(UUID.fromString(data.getUniqueId()), data.effectiveCharted()); + } catch (IllegalArgumentException e) { + // Not a player record - skip + } + }); + }); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onCharted(IslandChartedEvent event) { + refresh(event.getPlayerId()); + } + + /** + * Re-read one player's count - also the hook {@code /twadmin rank} uses + * after adjusting a player, so the board never shows a stale rank. + * + * @param playerId the player + */ + public void refresh(UUID playerId) { + counts.put(playerId, addon.getPlayerDataManager().get(playerId).effectiveCharted()); + } + + /** + * The top of the board, most-charted first. + * + * @param limit how many rows + * @return up to limit entries + */ + public List top(int limit) { + return counts.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .limit(limit) + .map(e -> new Entry(e.getKey(), e.getValue())) + .toList(); + } + + /** + * Expose ranks and the board to PlaceholderAPI, so scoreboard and tab + * plugins can show them without a plugin-side integration: + * {@code %tradewinds_rank%}, {@code %tradewinds_charted%}, + * {@code %tradewinds_top_name_1..10%}, {@code %tradewinds_top_charted_1..10%}. + */ + public void registerPlaceholders(RankService ranks) { + var placeholders = addon.getPlugin().getPlaceholdersManager(); + placeholders.registerPlaceholder(addon, "rank", user -> user == null ? "" + : user.getTranslation(ranks.rankFor(ranks.chartedCount(user.getUniqueId())).localeKey())); + placeholders.registerPlaceholder(addon, "charted", + user -> user == null ? "0" : String.valueOf(ranks.chartedCount(user.getUniqueId()))); + for (int i = 1; i <= 10; i++) { + final int place = i; + placeholders.registerPlaceholder(addon, "top_name_" + place, user -> rowName(place)); + placeholders.registerPlaceholder(addon, "top_charted_" + place, user -> rowCount(place)); + } + } + + private String rowName(int place) { + List board = top(place); + if (board.size() < place) { + return ""; + } + String name = addon.getPlayers().getName(board.get(place - 1).playerId()); + return name == null ? "" : name; + } + + private String rowCount(int place) { + List board = top(place); + return board.size() < place ? "" : String.valueOf(board.get(place - 1).charted()); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/HomePort.java b/src/main/java/world/bentobox/tradewinds/travel/HomePort.java new file mode 100644 index 0000000..28055e2 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/HomePort.java @@ -0,0 +1,80 @@ +package world.bentobox.tradewinds.travel; + +import java.util.Optional; +import java.util.UUID; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.IslandType; +import world.bentobox.tradewinds.galaxy.SecurityBand; + +/** + * A claimed islet seen through the travel system's eyes. + *

+ * Everything about warping is shaped around {@link IslandSpec} - destinations, + * route costs, arrival geometry, the interstice's owed jump - so a member's + * claimed island travels as a synthetic spec: its wild-grid cell, its + * real centre, and a sentinel tech level ({@link #HOME_TECH}) that marks it as + * a home wherever the difference matters (no dock to aim at, its own dialog + * labels). Only members ever see one, which is what makes the claim a + * member-only warp node. + * + * @author tastybento + */ +public final class HomePort { + + /** Sentinel tech level marking a synthetic home spec. */ + public static final int HOME_TECH = -1; + + private HomePort() { + // Static use only + } + + /** + * Whether a spec is a synthetic home rather than a trading island. + */ + public static boolean isHome(IslandSpec spec) { + return spec.techLevel() == HOME_TECH; + } + + /** + * The claimed island a player belongs to, if any. + */ + public static Optional islandOf(TradeWinds addon, UUID playerId) { + Island island = addon.getIslands().getIsland(addon.getOverWorld(), playerId); + if (island == null || !island.getMemberSet().contains(playerId) + || island.getMetaData(IsletClaimService.META_CLAIM).isEmpty()) { + return Optional.empty(); + } + return Optional.of(island); + } + + /** + * The player's home as a warp destination, if they have one. The name is + * the island's own if an admin gave it one, else the localized "Home". + */ + public static Optional specFor(TradeWinds addon, User user) { + return islandOf(addon, user.getUniqueId()).map(island -> { + int centerX = island.getCenter().getBlockX(); + int centerZ = island.getCenter().getBlockZ(); + int grid = Math.max(1, addon.getSettings().getWildIsletGrid()); + String name = island.getName() != null && !island.getName().isBlank() ? island.getName() + : user.getTranslation("tradewinds.home.port-name"); + return new IslandSpec(Math.floorDiv(centerX, grid), Math.floorDiv(centerZ, grid), + centerX, centerZ, IslandType.FISHING, bandAt(addon, centerX, centerZ), "", name, HOME_TECH); + }); + } + + /** + * The security band a position sits in, from plain distance to spawn - + * cosmetic here (home dialogs use their own labels), but honest enough + * for anything that colors by band. + */ + private static SecurityBand bandAt(TradeWinds addon, int x, int z) { + int bandRadius = Math.max(1, addon.getSettings().getBandRadius()); + int step = (int) (Math.hypot(x, z) / bandRadius); + return SecurityBand.values()[Math.clamp(step, 0, SecurityBand.values().length - 1)]; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index 8cc6ff2..7e1aa96 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -58,6 +58,9 @@ public class IntersticeService { /** Room a ghast needs under the interstice ceiling - they are 4 blocks tall. */ private static final int GHAST_CLEARANCE = 6; + /** Pending-destination marker for a home warp: not a galaxy cell. */ + private static final String HOME_PENDING = "home"; + /** Player -> the destination cell they are still owed, free of charge. */ private final Map pendingDestination = new ConcurrentHashMap<>(); private final Map lastPrompt = new ConcurrentHashMap<>(); @@ -148,16 +151,20 @@ public void strand(Player player, IslandSpec from, IslandSpec to) { if (addon.getNetherWorld() == null) { return; } - pendingDestination.put(player.getUniqueId(), to.cellX() + "," + to.cellZ()); + // A home destination is not a galaxy cell: mark it, and resolve it + // back through the player's island on re-engage (Stage 7b) + pendingDestination.put(player.getUniqueId(), + HomePort.isHome(to) ? HOME_PENDING : to.cellX() + "," + to.cellZ()); // Partway along the route (seeded-ish: middle third) double fraction = 0.35 + Math.random() * 0.3; int x = (int) Math.round(from.centerX() + (to.centerX() - from.centerX()) * fraction); int z = (int) Math.round(from.centerZ() + (to.centerZ() - from.centerZ()) * fraction); // Open water here too: the interstice has its own sea floor, and - // dropping a castaway inside it would be the same suffocation bug - // No galaxy in the interstice: no islands, no docks, and a floor that - // cannot reach the surface, so the intended point always serves - Location target = SeaArrival.openSeaNear(null, addon.getNetherWorld(), x, z, + // dropping a castaway inside it would be the same suffocation bug. + // The interstice now HAS land (wart shoals) and masonry (watchtowers) + // - the feature map is the column test the galaxy cannot provide + var map = addon.getIntersticeMap(addon.getNetherWorld().getSeed()); + Location target = SeaArrival.openSeaNear(map::isOpenWater, addon.getNetherWorld(), x, z, addon.getSettings().getIntersticeSeaHeight()); Entity vehicle = player.getVehicle(); @@ -417,6 +424,11 @@ private Optional owedDestination(Player player) { if (cell == null) { return Optional.empty(); } + if (HOME_PENDING.equals(cell)) { + // Unclaimed while stranded -> empty, and the caller falls back + // to the nearest charted island: nobody is ever stranded + return HomePort.specFor(addon, User.getInstance(player)); + } String[] parts = cell.split(","); return addon.getGalaxyEngine(addon.getOverWorld().getSeed()) .islandInCell(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); diff --git a/src/main/java/world/bentobox/tradewinds/travel/IsletClaimService.java b/src/main/java/world/bentobox/tradewinds/travel/IsletClaimService.java new file mode 100644 index 0000000..ee03001 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/IsletClaimService.java @@ -0,0 +1,144 @@ +package world.bentobox.tradewinds.travel; + +import java.util.HashMap; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; + +import world.bentobox.bentobox.api.events.island.IslandEvent; +import world.bentobox.bentobox.api.metadata.MetaDataValue; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.hooks.VaultHook; +import world.bentobox.bentobox.managers.IslandsManager; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.Islet; + +/** + * Claiming a wild islet: Stage 7's ownership. The land already exists - the + * generator made it - so a claim registers a real BentoBox island over it, no + * blueprint, no paste. First claimed, first owned: pillaged, wrecked or + * built-on makes no difference, and everything on the islet comes with it. + *

+ * The claim is gated twice: by Seafarer rank (earned by charting - money + * cannot buy it) and by price (config, above the top boat - the boat ladder + * comes first). Team members need neither: the claimer recruits whoever they + * like through the ordinary BentoBox team commands. + *

+ * Claimed islands get a SMALL range box - the islet's radius plus the config + * margin - unlike trading islands' full-distance boxes. BentoBox supports the + * mix ({@code isEnforceEqualRanges} is false), and it is what lets a base sit + * a few hundred blocks from a port without the grid refusing it. + * + * @author tastybento + */ +public class IsletClaimService { + + /** Island metadata key marking a claimed islet: "cellX,cellZ,radius". */ + public static final String META_CLAIM = "tradewinds-claim"; + + /** Why a claim was refused. */ + public enum Refusal { + NOT_ON_ISLET, ALREADY_CLAIMED, HAS_ISLAND, RANK_TOO_LOW, NO_ECONOMY, CANNOT_AFFORD, WATERS_TAKEN + } + + /** The outcome: a refusal, or the claimed island. */ + public record Result(Refusal refusal, Islet islet, Island island) { + public boolean success() { + return refusal == null; + } + } + + private final TradeWinds addon; + private final RankService ranks; + + public IsletClaimService(TradeWinds addon, RankService ranks) { + this.addon = addon; + this.ranks = ranks; + } + + /** + * Try to claim the wild islet the player is standing on. + * + * @param player the would-be owner, in the overworld + * @return the result + */ + public Result claim(Player player) { + World world = addon.getOverWorld(); + GalaxyEngine engine = addon.getGalaxyEngine(world.getSeed()); + Optional standing = engine.isletAt(player.getLocation().getBlockX(), + player.getLocation().getBlockZ()); + if (standing.isEmpty()) { + return new Result(Refusal.NOT_ON_ISLET, null, null); + } + Islet islet = standing.get(); + IslandsManager islands = addon.getIslands(); + // One island per player, the BentoBox rule - membership counts + if (islands.getIsland(world, player.getUniqueId()) != null) { + return new Result(Refusal.HAS_ISLAND, islet, null); + } + Location center = new Location(world, islet.centerX() + 0.5, + world.getHighestBlockYAt(islet.centerX(), islet.centerZ()) + 1.0, islet.centerZ() + 0.5); + // A previous claim over this islet refuses outright; an unowned + // trading island's box is handled by the grid insert below + Optional at = islands.getIslandAt(center); + if (at.isPresent() && at.get().getOwner() != null) { + return new Result(Refusal.ALREADY_CLAIMED, islet, null); + } + if (ranks.chartedCount(player.getUniqueId()) < ranks.claimThreshold()) { + return new Result(Refusal.RANK_TOO_LOW, islet, null); + } + Optional vault = addon.getPlugin().getVault(); + if (vault.isEmpty()) { + addon.logError("Islet claim refused: no Vault economy is installed"); + return new Result(Refusal.NO_ECONOMY, islet, null); + } + User user = User.getInstance(player); + double price = addon.getSettings().getClaimPrice(); + if (vault.get().getBalance(user) < price) { + return new Result(Refusal.CANNOT_AFFORD, islet, null); + } + Island island = register(islet, center, player.getUniqueId()); + if (island == null) { + return new Result(Refusal.WATERS_TAKEN, islet, null); + } + // Withdraw only after the grid accepted the island - the insert is the + // one step that can still refuse, and a refund path is a bug farm + vault.get().withdraw(user, price); + return new Result(null, islet, island); + } + + /** + * Register the claim as a BentoBox island. Mimics + * {@code IslandsManager.createIsland} but sizes the range box to the islet + * BEFORE the grid insert - createIsland would insert at the full island + * distance and collide with the nearest port's box. + */ + private Island register(Islet islet, Location center, UUID owner) { + int protection = islet.radius() + addon.getSettings().getClaimProtectionMargin(); + Island island = new Island(center, owner, protection); + String gmName = addon.getDescription().getName(); + island.setGameMode(gmName); + island.setUniqueId(gmName + island.getUniqueId()); + island.setRange(protection); + if (!addon.getIslands().getIslandCache().addIsland(island)) { + return null; + } + if (island.getMetaData().isEmpty()) { + island.setMetaData(new HashMap<>()); + } + int grid = addon.getSettings().getWildIsletGrid(); + island.putMetaData(META_CLAIM, new MetaDataValue(Math.floorDiv(islet.centerX(), grid) + "," + + Math.floorDiv(islet.centerZ(), grid) + "," + islet.radius())); + IslandsManager.saveIsland(island); + // The ecosystem hook Bank and friends listen for - a claim is a + // registration of existing land, the admin-register shape + IslandEvent.builder().island(island).location(center).involvedPlayer(owner) + .reason(IslandEvent.Reason.REGISTERED).build(); + return island; + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/RankService.java b/src/main/java/world/bentobox/tradewinds/travel/RankService.java new file mode 100644 index 0000000..259b282 --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/travel/RankService.java @@ -0,0 +1,148 @@ +package world.bentobox.tradewinds.travel; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import world.bentobox.tradewinds.TradeWinds; + +/** + * Seafarer ranks: a ladder climbed by charting islands. The rank table lives in + * config (slug -> charted-island threshold), display names in the locale + * under {@code tradewinds.rank.} - so admins retune the ladder without + * touching code, and every name can be translated. + *

+ * Rank is the game's notion of "level": Stage 7 gates islet claiming on it, + * and the leaderboard ranks players by the same charted count. It is earned by + * sailing - money cannot buy it, and the starter cluster the game pre-charts + * is deliberately below the first real rung. + * + * @author tastybento + */ +public class RankService { + + /** A rung of the ladder. */ + public record Rank(String slug, int threshold) { + /** The locale key carrying this rank's display name. */ + public String localeKey() { + return "tradewinds.rank." + slug; + } + } + + private final TradeWinds addon; + private final List ladder; // ascending by threshold + + public RankService(TradeWinds addon) { + this.addon = addon; + this.ladder = addon.getSettings().getRankThresholds().entrySet().stream() + .map(e -> new Rank(e.getKey(), e.getValue())) + .sorted(Comparator.comparingInt(Rank::threshold)) + .toList(); + if (ladder.isEmpty() || ladder.get(0).threshold() > 0) { + addon.logWarning("ranks.thresholds has no rank at 0 charted islands - " + + "players below the first threshold will show the lowest configured rank"); + } + } + + /** + * How many islands a player has charted, as the ladder sees it: the real + * chart plus any admin adjustment. + * + * @param playerId the player + * @return effective charted island count + */ + public int chartedCount(UUID playerId) { + return addon.getPlayerDataManager().get(playerId).effectiveCharted(); + } + + /** + * Admin override: make a player's effective charted count equal target, + * by storing the difference from their real chart as a bonus (negative to + * demote). Their real charting keeps counting on top, so a promoted + * player still climbs. The full ladder, for admin tab-completion. + * + * @param playerId the player + * @param target the effective charted count wanted + */ + public void setEffectiveCharted(UUID playerId, int target) { + var data = addon.getPlayerDataManager().get(playerId); + data.setChartedBonus(target - data.getChartedIslands().size()); + addon.getPlayerDataManager().save(playerId); + } + + /** + * Clear any admin adjustment: back to the real chart. + * + * @param playerId the player + */ + public void clearAdjustment(UUID playerId) { + addon.getPlayerDataManager().get(playerId).setChartedBonus(0); + addon.getPlayerDataManager().save(playerId); + } + + /** + * The whole ladder, lowest rung first. + * + * @return the ranks + */ + public List ladder() { + return ladder; + } + + /** + * The rank a charted count earns: the highest threshold at or below it. + * + * @param charted charted island count + * @return the rank; the lowest rung if the count is below every threshold + */ + public Rank rankFor(int charted) { + Rank current = ladder.get(0); + for (Rank rank : ladder) { + if (rank.threshold() > charted) { + break; + } + current = rank; + } + return current; + } + + /** + * The next rung above a charted count, if any. + * + * @param charted charted island count + * @return the next rank, or empty at the top of the ladder + */ + public Optional next(int charted) { + return ladder.stream().filter(rank -> rank.threshold() > charted).findFirst(); + } + + /** + * Look a rank up by its config slug. + * + * @param slug the slug + * @return the rank, or empty if the table has no such rung + */ + public Optional bySlug(String slug) { + return ladder.stream().filter(rank -> rank.slug().equalsIgnoreCase(slug)).findFirst(); + } + + /** + * The charted-island count required to claim a wild islet: the threshold of + * the configured {@code claims.minimum-rank}. A slug that names no rung is + * a config mistake and fails CLOSED - the top threshold, not zero - so a + * typo never hands out islands. + * + * @return charted islands required to claim + */ + public int claimThreshold() { + String slug = addon.getSettings().getClaimMinimumRank(); + Optional rank = bySlug(slug); + if (rank.isEmpty()) { + addon.logError("claims.minimum-rank '" + slug + "' is not in ranks.thresholds - " + + "requiring the top rank instead"); + return ladder.get(ladder.size() - 1).threshold(); + } + return rank.get().threshold(); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java b/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java index 9f5c28c..795cd1a 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java +++ b/src/main/java/world/bentobox/tradewinds/travel/SeaArrival.java @@ -163,4 +163,34 @@ public static Location openSeaNear(GalaxyEngine engine, World world, int x, int // it. Better a strange arrival than a suffocation. return new Location(world, x + 0.5, engine.surfaceHeightAt(x, z) + 1.0, z + 0.5); } + + /** A column test for worlds the galaxy does not describe. */ + public interface ColumnTest { + boolean isOpen(int x, int z); + } + + /** + * The nearest open water by an arbitrary column test - the interstice's + * variant. Its floor could never reach the surface, so a stranding used + * to take the intended point unexamined; wart shoals and watchtowers + * (interstice plan) ended that innocence. + * + * @param open the column test + * @param world the world to arrive in + * @param x intended block x + * @param z intended block z + * @param seaLevel the sea surface Y + * @return a location on open water (the intended point if the search + * finds nothing, which shoal densities keep effectively impossible) + */ + public static Location openSeaNear(ColumnTest open, World world, int x, int z, int seaLevel) { + for (int[] offset : searchOffsets(SEARCH_RADIUS, STEP)) { + int cx = x + offset[0]; + int cz = z + offset[1]; + if (open.isOpen(cx, cz)) { + return new Location(world, cx + 0.5, seaLevel + 1.0, cz + 0.5); + } + } + return new Location(world, x + 0.5, seaLevel + 1.0, z + 0.5); + } } diff --git a/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java b/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java index 63e98b8..0b9c8a2 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java +++ b/src/main/java/world/bentobox/tradewinds/travel/StarChartRenderer.java @@ -36,6 +36,8 @@ public class StarChartRenderer extends MapRenderer { private static final Color RANGE_RING = new Color(120, 220, 235); /** Names are drawn white: anything darker vanishes against the ocean. */ private static final Color NAME = new Color(255, 255, 255); + /** Home is gold: the one dot on the chart that is YOURS. */ + private static final Color HOME = new Color(235, 190, 50); private final TradeWinds addon; private final Map lastDraw = new HashMap<>(); @@ -128,6 +130,23 @@ public void render(MapView view, MapCanvas canvas, Player player) { } }); } + // HOME: a member's claimed islet, in gold - drawn after the islands + // so nothing sits on top of it (Stage 7b) + HomePort.islandOf(addon, player.getUniqueId()).ifPresent(island -> { + int[] pixel = toPixel(island.getCenter().getBlockX() - (long) px, + island.getCenter().getBlockZ() - (long) pz, bpp); + String name = island.getName() != null && !island.getName().isBlank() ? island.getName() + : world.bentobox.bentobox.api.user.User.getInstance(player) + .getTranslation("tradewinds.home.port-name"); + if (pixel[2] == 1) { + canvas.setPixelColor(pixel[0], pixel[1], HOME); + drawName(canvas, pixel[0], pixel[1], name); + } else { + fillDot(canvas, pixel[0], pixel[1], + Math.max(1, island.getProtectionRange() / bpp), HOME); + drawName(canvas, pixel[0], pixel[1], name); + } + }); // The holder: a cursor arrow at center, rotating with their facing MapCursorCollection cursors = new MapCursorCollection(); byte direction = (byte) (Math.round(player.getLocation().getYaw() * 16.0 / 360.0) & 15); diff --git a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java index ca5b7ab..710e94b 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/WarpService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/WarpService.java @@ -65,7 +65,8 @@ public record Destination(IslandSpec island, int fuelCost, boolean affordable) { public List destinations(Player player, IslandSpec origin, double fuelAboard) { GalaxyEngine engine = addon.getGalaxyEngine(addon.getOverWorld().getSeed()); RouteGraph routes = addon.getRouteGraph(); - return addon.getPlayerDataManager().get(player.getUniqueId()).getChartedIslands().stream() + List ports = addon.getPlayerDataManager().get(player.getUniqueId()).getChartedIslands() + .stream() .map(key -> key.split(",")) .map(cell -> engine.islandInCell(Integer.parseInt(cell[0]), Integer.parseInt(cell[1]))) .flatMap(java.util.Optional::stream) @@ -77,6 +78,18 @@ public List destinations(Player player, IslandSpec origin, double f return new Destination(spec, cost, cost <= fuelAboard); }) .toList(); + // A member's claimed islet is a warp node for THEM: pinned at the + // top, normal fuel rules, invisible to everyone else (Stage 7b) + java.util.Optional home = HomePort.specFor(addon, user(player)).filter( + spec -> !(spec.cellX() == origin.cellX() && spec.cellZ() == origin.cellZ())); + if (home.isEmpty()) { + return ports; + } + List all = new java.util.ArrayList<>(); + int cost = routes.cost(origin, home.get()); + all.add(new Destination(home.get(), cost, cost <= fuelAboard)); + all.addAll(ports); + return all; } /** @@ -108,13 +121,30 @@ public void openDialog(Player player, IslandSpec origin) { private ActionButton button(Player player, IslandSpec origin, Destination dest) { IslandSpec spec = dest.island(); + String distance = String.valueOf((int) Math.sqrt(spec.distanceSquared(origin.centerX(), + origin.centerZ()))); + if (HomePort.isHome(spec)) { + // A home has no port sheet - no type, no tech, no market + Component homeLabel = user(player).getTranslationAsComponent( + dest.affordable() ? "tradewinds.ui.warp.home" : "tradewinds.ui.warp.home-poor", + "[name]", spec.name(), "[fuel]", String.valueOf(dest.fuelCost())); + Component homeTooltip = user(player).getTranslationAsComponent( + "tradewinds.ui.warp.home-tooltip", "[distance]", distance); + return ActionButton.create(homeLabel, homeTooltip, 250, DialogAction.customClick( + (response, audience) -> { + if (dest.affordable()) { + warp(player, origin, spec, dest.fuelCost()); + } else { + user(player).sendMessage("tradewinds.warp.not-enough-fuel"); + } + }, ClickCallback.Options.builder().build())); + } Component label = user(player).getTranslationAsComponent( dest.affordable() ? "tradewinds.ui.warp.destination" : "tradewinds.ui.warp.destination-poor", "[name]", spec.name(), "[fuel]", String.valueOf(dest.fuelCost())); Component tooltip = user(player).getTranslationAsComponent("tradewinds.ui.warp.destination-tooltip", "[type]", spec.type().name(), "[tech]", String.valueOf(spec.techLevel()), - "[band]", spec.band().getDisplayName(), "[distance]", - String.valueOf((int) Math.sqrt(spec.distanceSquared(origin.centerX(), origin.centerZ())))); + "[band]", spec.band().getDisplayName(), "[distance]", distance); DialogAction action = DialogAction.customClick( (response, audience) -> { if (dest.affordable()) { @@ -238,14 +268,23 @@ private void deliver(Player player, IslandSpec to, IslandSpec bearingFrom, int f Location target = SeaArrival.openSeaOutward(addon.getGalaxyEngine(addon.getOverWorld().getSeed()), addon.getOverWorld(), to.centerX(), to.centerZ(), arrive[0], arrive[1], addon.getSettings().getSeaHeight()); - // Face the boat (and the sailor) at the destination's PIER: paddling - // straight ahead from a warp arrival is always the way in - world.bentobox.tradewinds.galaxy.DockPlan plan = addon - .getGalaxyEngine(addon.getOverWorld().getSeed()).dockPlan(to); - // The dock FLAG: the banner at the pier end, which is what a sailor - // actually steers for (IslandDecorator plants it 2 blocks short) - double pierX = to.centerX() + Math.cos(plan.bearing()) * (plan.dockEnd() - 2); - double pierZ = to.centerZ() + Math.sin(plan.bearing()) * (plan.dockEnd() - 2); + // Face the boat (and the sailor) at the way in: the destination's + // PIER for a port - paddling straight ahead from a warp arrival is + // always the way in - or the islet's centre for a home, which has no + // dock to steer for + double pierX; + double pierZ; + if (HomePort.isHome(to)) { + pierX = to.centerX(); + pierZ = to.centerZ(); + } else { + world.bentobox.tradewinds.galaxy.DockPlan plan = addon + .getGalaxyEngine(addon.getOverWorld().getSeed()).dockPlan(to); + // The dock FLAG: the banner at the pier end, which is what a sailor + // actually steers for (IslandDecorator plants it 2 blocks short) + pierX = to.centerX() + Math.cos(plan.bearing()) * (plan.dockEnd() - 2); + pierZ = to.centerZ() + Math.sin(plan.bearing()) * (plan.dockEnd() - 2); + } // The plain look-at yaw IS correct: the console proved it (computed // 45.8 where the sailor's own F3 read 46.3 for the right heading). // The quarter-turn "hull offset" of the first fix was chasing a diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index c655766..0742415 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -14,9 +14,10 @@ permissions: default: true tradewinds.island.spawn: description: > - Allow /tw go - the door into the ocean. It refuses while the player is - already at sea and returns them to the water they left, so it is not a - free warp home. + Allow /tw go - the door into the ocean, and the way home within the + rules. From outside it returns the player to the water they left; on + their own island it steps them to their home point; anywhere else at + sea it tells them the direction and distance home, never a ride. default: true tradewinds.island.warp: description: > @@ -39,6 +40,31 @@ permissions: tradewinds.island.restart: description: Allow restarting the trading career (config-capped) default: true + tradewinds.island.rank: + description: Allow viewing Seafarer rank and the charted-islands top ten + default: true + tradewinds.island.claim: + description: > + Allow claiming a wild islet as a player island. The real gates are in + config - Seafarer rank (claims.minimum-rank) and price (claims.price) - + so this stays on by default; turn it off to disable claiming outright. + default: true + tradewinds.island.sethome: + description: > + Allow setting a home point on your own island - only while standing + inside its protection range (owner or team member). + default: true + tradewinds.island.home: + description: > + Allow teleporting to your home point - only while already standing on + your own island, so it is a convenience around the base and never a way + home across the sea. + default: true + tradewinds.island.unclaim: + description: > + Allow the owner to give a claimed islet back to the wild. Requires the + team to be emptied first; no refund; every block stays as it was left. + default: true tradewinds.island.prices: description: > Allow the trader's logbook - what ports you have called at were paying, @@ -77,6 +103,12 @@ permissions: tradewinds.admin.priceaudit: description: Audit price coverage and write the report to price-audit.txt default: op + tradewinds.admin.rank: + description: > + Inspect or set a player's Seafarer rank - stores an adjustment on top of + their real charted count, so promotion, demotion and reset all work and + the claim gate and leaderboard follow. + default: op tradewinds.admin.reflag: description: Allow re-applying security-band flags to all islands default: op diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 87a5838..083f122 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -174,6 +174,42 @@ bands: port-denied-flags: [] # Flag IDs forced back to visitor rank, overriding the deny list, e.g. [HARVEST]. port-allowed-flags: [] +ranks: + # Seafarer rank ladder: rank slug -> charted islands required. A player's rank + # is the highest threshold at or below their charted count. Display names live + # in the locale under tradewinds.rank. - add a locale name for any new + # slug, keep exactly one rank at 0 so every sailor has a rank, and avoid dots + # in slugs (YAML nests on them). + thresholds: + deck-hand: 0 + cabin-scout: 6 + coast-finder: 8 + bay-explorer: 10 + island-hopper: 12 + reef-runner: 15 + wave-master: 17 + chart-maker: 20 + storm-navigator: 25 + archipelago-ace: 30 + tide-captain: 36 + horizon-hunter: 42 + sea-sentinel: 49 + salty-commodore: 56 + ocean-overlord: 64 + pirate-pathfinder: 72 + grand-admiral: 81 + globe-voyager: 90 + mythic-mariner: 100 +claims: + # Price of claiming a wild islet, in whole coins. Deliberately above the top + # boat (Pale Oak Chest Boat, 110,250 at defaults): the boat ladder comes first. + price: 150000 + # Minimum Seafarer rank (a slug from ranks.thresholds) required to claim a + # wild islet. Team members need no rank - only the claimer. + minimum-rank: tide-captain + # Blocks of protected water beyond the islet's terrain radius. The protection + # range is islet radius + this margin. + protection-margin: 32 crime: # Master switch for the whole law layer: reputation, customs scans, police and # bounties. False makes the world lawless in the sense of 'nothing is tracked'. @@ -251,6 +287,10 @@ chart: hud: # Navigation boss bar in island waters: island name, standing, distance to dock. navigation-bossbar: true + # Distance in blocks at which the course-home bar (toggled by /tw go at sea) + # reads empty at night; it fills as home approaches. By day the bar shows + # direction only - the stars are the precise instrument. + course-bar-scale: 5000 economy: # Money given to brand-new players with their starter kit. starting-balance: 2500.0 @@ -473,8 +513,11 @@ border: boats: # The boat ladder: cargo slots per boat material. A player has exactly ONE # boat (or none) and this is the ONLY thing that sizes their hold. Order is - # by slots; shops list only boats bigger than yours, up to the island's tech - # (rank <= tech level x ranks-per-tech-level). Crafting ignores tech gates. + # by slots; shops list every hull the island's tech can build (rank <= tech + # level x ranks-per-tech-level) except the size you sail. A bigger hull + # with your ship at the quay is a trade-in (cargo stays); anything else is + # bought outright and your old boat is left unowned where it lies. + # Crafting ignores tech gates. ranks: BAMBOO_RAFT: 2 OAK_BOAT: 3 @@ -533,6 +576,31 @@ interstice: # Chance per chunk of a burning netherrack brazier rising out of the interstice # sea. They are the only light out there - without them it is a flat black nothing. brazier-chance: 0.18 + # --- Interstice resources (tradewinds-interstice-plan.md). Changing the + # needsReset ones moves every feature: regenerate tradewinds_world_nether. --- + # Wart shoals: grid size in blocks for the soul-sand banks that break the + # surface, chance (0-1) a cell hosts one, and their mean radius. 0 disables. + shoal-grid: 256 + shoal-chance: 0.5 + shoal-radius: 9 + # Chance (0-1) a shoal is a GRAND shoal carrying a crimson or warped grove. + grand-shoal-chance: 0.2 + # Fraction of a shoal's soul-sand surface planted with nether wart. + wart-density: 0.35 + # Chance (0-1) that a brazier grows a nether-brick turret with a blaze + # spawner - the blaze pickets. + blaze-picket-chance: 0.25 + # Chance per ceiling chunk of a glowstone cluster hanging from the lid. + # Needs ceiling-height > 0. + glowstone-cluster-chance: 0.2 + # Chance per submerged shoal-core or brazier-root block of quartz ore. + quartz-chance: 0.12 + # Wither watchtowers: grid size in blocks and chance (0-1) a cell hosts one - + # the interstice's only structure. Wither skeletons spawn inside; the loot + # chest uses the vanilla loot table named below. + watchtower-grid: 1536 + watchtower-chance: 0.6 + watchtower-loot-table: "minecraft:chests/bastion_treasure" # Seconds after a failed warp during which nothing in the interstice may target or # hurt the player - long enough to read the dialog and take the free way out. grace-seconds: 20 @@ -559,6 +627,14 @@ encounters: ANARCHIC: 0.28 # Chance a spawned drowned carries a trident. drowned-trident-chance: 0.35 + # How close a player must be (blocks) before a boated crew abandons ship, + # per encounter type. Pirates fight from the deck and only jump at boarding + # distance; the witch bails wide - potions overshoot from a drifting + # platform. Must be BELOW encounters.distance or the crew ejects on its + # first tick and nobody ever sees a manned boat. + abandon-ship: + PIRATE_CREW: 10 + SEA_WITCH: 30 # Chance that a slain encounter mob yields booty, and what it may drop. # Booty is customs-stamped, so it can be sold: fighting is the third way to earn. booty-chance: 0.5 @@ -572,6 +648,10 @@ encounters: - COAL - COOKED_COD - TRIDENT + # Nether goods trickle in through piracy too, for sailors who never fail + # a warp (tradewinds-interstice-plan.md, N3) + - NETHER_WART + - GHAST_TEAR illegal-trade: # The contraband list: what customs care about, and what black markets pay # the premium for. Only honored while illegal-trade.enabled is true. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index dd10a2b..771a637 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -42,6 +42,16 @@ tradewinds: description: "restart your trading career (limited uses)" fine: description: "settle your criminal record at a port (returns you to Clean)" + rank: + description: "your Seafarer rank and the top ten sailors by islands charted" + claim: + description: "claim the wild islet you are standing on as your island" + sethome: + description: "set your home point (while standing on your own island)" + home: + description: "step to your home point (while standing on your own island)" + unclaim: + description: "give your claimed islet back to the wild (owner only, team must be empty, no refund)" admin: help: description: "admin command for TradeWinds" @@ -81,6 +91,11 @@ tradewinds: parameters: "" rigged: "[name]'s next warp will fail. Run this again to clear it." cleared: "[name]'s warp is no longer rigged." + rank: + description: "inspect or set a player's Seafarer rank (by rank name, island count, or 'reset')" + parameters: " [rank|islands|reset]" + current: "[name] is a [rank] - [number] islands ([real] charted, [bonus] adjustment)." + unknown-rank: "No such rank: [name]. Use a rank slug from the config, an island count, or 'reset'." reflag: description: "re-apply security-band flags to all trading islands" done: "Re-applied band flags to [number] trading islands." @@ -125,6 +140,67 @@ tradewinds: escaped: "Open water. [name] loses you - but its harbourmaster will not forget." called-off: "[name]'s patrol gives up the search and turns for home. You still have the cargo." caught: "Boarded. [number] items seized, [amount] in fines, and [name] has your name." + rank: + # Seafarer rank names, one per slug in config ranks.thresholds + deck-hand: "Deck Hand" + cabin-scout: "Cabin Scout" + coast-finder: "Coast Finder" + bay-explorer: "Bay Explorer" + island-hopper: "Island Hopper" + reef-runner: "Reef Runner" + wave-master: "Wave Master" + chart-maker: "Chart Maker" + storm-navigator: "Storm Navigator" + archipelago-ace: "Archipelago Ace" + tide-captain: "Tide Captain" + horizon-hunter: "Horizon Hunter" + sea-sentinel: "Sea Sentinel" + salty-commodore: "Salty Commodore" + ocean-overlord: "Ocean Overlord" + pirate-pathfinder: "Pirate Pathfinder" + grand-admiral: "Grand Admiral" + globe-voyager: "Globe Voyager" + mythic-mariner: "Mythic Mariner" + # /tw rank output + own: "You are a [rank] - [number] islands charted." + next: "Next: [rank] - [number] more islands." + top-of-ladder: "The ladder has no rung above you. The sea is yours." + board-header: "Most travelled sailors:" + board-entry: "[place]. [name] - [number] islands ([rank])" + claim: + success: "[title]Land ho![subtitle]This islet is yours.The islet is yours for [price]. Invite your crew with the team commands - and defend what is yours." + not-on-islet: "No wild islet underfoot. Stand on the islet you mean to claim." + already-claimed: "This islet is already claimed." + has-island: "You already belong to an island. Unclaim or leave it first." + rank-too-low: "No harbourmaster deals land to an unproven sailor: claiming needs the rank of [rank] ([number] islands charted - you have [charted])." + no-economy: "No economy is running - claiming is disabled on this server." + cannot-afford: "Land costs money: [price], paid in full at the claim." + waters-taken: "These are a port's waters - claim an islet further from the trading lanes." + home: + no-island: "You have no island. Claim a wild islet first." + not-on-island: "Only aboard your own island. Homes are arranged from the shore, not commanded across the sea." + set: "Home set. You will respawn here, and /tw home steps here while you are on the island." + teleported: "You are home." + bearing: "Home lies to the [direction], [number] blocks away. Fair winds." + port-name: "Home" + course-on: "Course set. By day the sun gives the quarter; by night the stars give the distance too. /tw go again to put the sky away." + course-off: "You stop watching the sky." + course-arrived: "Home waters. The course has done its job." + unclaim: + owner-only: "Only the owner can give an island back to the wild." + not-a-claim: "This island was never a claimed islet - it is not yours to drop." + team-first: "Your crew still holds this island. Kick every member first - unclaiming is meant to hurt." + confirm: "Give the islet back to the wild? No refund. Everything built stays, and the first sailor to land may claim it all." + done: "The islet is wild again. What you built stands - until someone else claims it." + direction: + north: "north" + north-east: "north-east" + east: "east" + south-east: "south-east" + south: "south" + south-west: "south-west" + west: "west" + north-west: "north-west" band: safe: "Safe" policed: "Policed" @@ -136,11 +212,14 @@ tradewinds: low-chat: "Low fuel at this port. You have [fuel] units; the cheapest warp from here needs [needed]. Buy at least [number] more from the market before you leave, or you will be rowing." hud: navigation: "[name] | Tech [tech] | [band] | [standing] | Dock [distance]m" + course-day: "By the sun: home lies to the [direction]. The stars will tell you more." + course-night: "By the stars: home lies to the [direction], [distance] blocks" hologram: island: "[name][type] T[tech], [band][distance]m" boat: "BOAT[distance]m" old-boat: "OLD BOAT[distance]m" dock: "DOCK[name][distance]m" + home: "HOME[distance]m" ui: capture: title: "The [material]" @@ -191,7 +270,7 @@ tradewinds: outfitter-title: "[name] - Outfitter" outfitter-body: "Stores for the sailing life - food, fuel and gear" shipwright-title: "[name] - Shipwright" - shipwright-body: "Upgrades only: your old boat is taken in and broken up, your cargo stays aboard. Smart sailors craft rare-wood boats instead of paying." + shipwright-body: "With your ship at the quay, a bigger hull is a trade-in: old hull broken up, your cargo stays aboard. Anything else is bought outright - your old boat is left unowned wherever it lies. Smart sailors craft rare-wood boats instead of paying." sell-pick: "[material] x[amount]" sell-pick-tooltip: "[price] each. This port will take about [depth] more at that price." sell-qty-one: "Sell 1 - [price]" @@ -206,7 +285,7 @@ tradewinds: buy-batch: "x[amount] - [price]" buy-tooltip-each: "[price] each; limited by balance and hold space" outfit-one: "[material] x1 - [price]" - outfit-batch: "x[amount] - [price]" + outfit-batch: "[material] x[amount] - [price]" outfit-tooltip: "[price] each - carried in your pack, not the hold" hull: "[material] - [price]" hull-tooltip: "[slots] cargo slots. Replaces your current boat; contents stay put." @@ -224,6 +303,9 @@ tradewinds: destination: "[name] - [fuel] fuel" destination-poor: "[name] - [fuel] fuel" destination-tooltip: "[type] (Tech [tech]), [band], [distance] blocks" + home: "⌂ [name] - [fuel] fuel" + home-poor: "⌂ [name] - [fuel] fuel" + home-tooltip: "Your island, [distance] blocks. Members only - nobody else ever sees this entry." interstice: title: "The Interstice" body: "Dark water, and something screaming above it. Your course is still paid for." @@ -241,6 +323,8 @@ tradewinds: expander-lore: "Expands your hold. Right-click to open." stamp: "Customs Stamped" starchart: "Star Chart" + ships-compass: "Ship's Compass" + ships-compass-lore: "Points to your island, from anywhere. Guard it - or gift it to a crewmate." expander-view: "Cargo Expander" chart: charted: "[actionbar]Charted [name]!" @@ -314,8 +398,9 @@ tradewinds: not-at-market: "There is no market here - trade within an island's protection range." nothing-of-that: "Nothing of that in your hold." boat-bought: "[material] purchased for [price] - [slots] cargo slots. Your old boat was broken up; your cargo is aboard." + boat-bought-first: "[material] purchased for [price] - [slots] cargo slots. She is in your pack: find water and set her down." + boat-replaced: "[material] purchased for [price] - [slots] cargo slots, in your pack. Your [old] and everything aboard is left unowned where it lies - /tw chart remembers the spot, but so might someone else." boat-not-here: "Your ship is not at this port - bring it into the island's waters to trade." - refit-needs-ship: "The yard cannot refit a ship that is not here - bring it into the island's waters." boat-not-sold-here: "This yard cannot sell you that boat." boat-crafted: "You fit out the [material] - [slots] cargo slots. Your old boat is broken up; your cargo is aboard." craft-smaller-refused: "That boat is no bigger than yours - you would be burning good wood to shrink your hold." @@ -344,7 +429,9 @@ tradewinds: expander-cap: "Your boat cannot take any more expanders." interstice: stranded: "The warp collapses! Dark water, and screaming above it." + no-portal: "The frame drinks the flame and goes cold. The sea between seas does not open to fire and stone." encounter: + puffer_shoal: "The water ahead prickles - a shoal of pufferfish across your course. Mind your hands." guardian_picket: "Guardians rise in your path." drowned_raiders: "Drowned surface off the bow - some carry tridents." deep_terror: "Something vast and ancient rises from the deep. Run." diff --git a/src/test/java/world/bentobox/tradewinds/commands/CompassBearingTest.java b/src/test/java/world/bentobox/tradewinds/commands/CompassBearingTest.java new file mode 100644 index 0000000..1a6a22c --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/commands/CompassBearingTest.java @@ -0,0 +1,37 @@ +package world.bentobox.tradewinds.commands; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * The eight-point bearing behind "/tw go" pointing home: north is -Z, east is + * +X, and the boundaries split at 22.5 degrees either side of each point. + * + * @author tastybento + */ +class CompassBearingTest { + + @Test + void testCardinals() { + assertEquals("tradewinds.direction.north", TWSpawnCommand.compassKey(0, -100)); + assertEquals("tradewinds.direction.south", TWSpawnCommand.compassKey(0, 100)); + assertEquals("tradewinds.direction.east", TWSpawnCommand.compassKey(100, 0)); + assertEquals("tradewinds.direction.west", TWSpawnCommand.compassKey(-100, 0)); + } + + @Test + void testDiagonals() { + assertEquals("tradewinds.direction.north-east", TWSpawnCommand.compassKey(100, -100)); + assertEquals("tradewinds.direction.south-east", TWSpawnCommand.compassKey(100, 100)); + assertEquals("tradewinds.direction.south-west", TWSpawnCommand.compassKey(-100, 100)); + assertEquals("tradewinds.direction.north-west", TWSpawnCommand.compassKey(-100, -100)); + } + + @Test + void testBoundariesRoundToTheNearestPoint() { + // 20 degrees east of north is still north; 25 degrees is north-east + assertEquals("tradewinds.direction.north", TWSpawnCommand.compassKey(36, -100)); + assertEquals("tradewinds.direction.north-east", TWSpawnCommand.compassKey(47, -100)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java index 05efbda..19cb10a 100644 --- a/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java +++ b/src/test/java/world/bentobox/tradewinds/economy/SaleCatalogTest.java @@ -89,14 +89,18 @@ void testBoatLadderShop() { // Quadratic prices, in whole coins: raft 1000, top boat 110250 assertTrue(ranks.price(ladder.get(0)) == 1000.0); assertTrue(ranks.price(ladder.get(19)) == 110250.0); - // Shops list bigger-only, tech-gated: TL1 sells ranks 1-3 only + // Shops are tech-gated: TL1 sells ranks 1-3 only var tl1 = ranks.shopListing(null, 1); assertTrue(tl1.size() == 3 && tl1.get(2).material() == Material.SPRUCE_BOAT); - // With a spruce boat at TL1 there is nothing left to buy - assertTrue(ranks.shopListing(Material.SPRUCE_BOAT, 1).isEmpty()); - // TL7 sells everything bigger than yours - assertTrue(ranks.shopListing(Material.CHERRY_CHEST_BOAT, 7).size() == 1); - assertTrue(ranks.shopListing(Material.PALE_OAK_CHEST_BOAT, 7).isEmpty(), "Top of the tree"); + // The yard ALWAYS sells (ruled 2026-08-05): with a spruce boat at TL1 + // the smaller hulls are still on offer - bought outright, the spruce + // left unowned wherever it lies. Only the size you sail is excluded. + var downgrade = ranks.shopListing(Material.SPRUCE_BOAT, 1); + assertTrue(downgrade.size() == 2, "TL1 with a spruce should offer the two smaller hulls"); + assertTrue(downgrade.stream().noneMatch(r -> r.material() == Material.SPRUCE_BOAT)); + // TL7 sells the whole ladder except the size you sail + assertTrue(ranks.shopListing(Material.PALE_OAK_CHEST_BOAT, 7).size() == 19, + "Top of the tree still gets everything smaller"); } @Test diff --git a/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java b/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java index ff2d0f7..5f242af 100644 --- a/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java +++ b/src/test/java/world/bentobox/tradewinds/encounters/EncounterTableTest.java @@ -35,9 +35,23 @@ void testRiskRisesWithDistance() { @Test void testSafeWatersAreTamest() { - // Nothing at all hunts in SAFE space: every encounter needs POLICED+ - assertTrue(EncounterTable.pick(SecurityBand.SAFE, true, 0.5).isEmpty()); - assertTrue(EncounterTable.pick(SecurityBand.SAFE, false, 0.5).isEmpty()); + // SAFE space gets the puffer shoal and NOTHING else - spice, not + // danger (ruled 2026-08-06; before that the 2% roll picked from an + // empty table and the config entry was decorative) + Set day = rollAll(SecurityBand.SAFE, true); + Set night = rollAll(SecurityBand.SAFE, false); + assertEquals(Set.of(EncounterType.PUFFER_SHOAL), day); + assertEquals(Set.of(EncounterType.PUFFER_SHOAL), night); + } + + @Test + void testNuisanceStaysOutOfTheRoughBands() { + // Beyond POLICED the puffer shoal would only dilute real danger + assertTrue(rollAll(SecurityBand.POLICED, true).contains(EncounterType.PUFFER_SHOAL)); + assertFalse(rollAll(SecurityBand.FRONTIER, true).contains(EncounterType.PUFFER_SHOAL)); + assertFalse(rollAll(SecurityBand.ANARCHIC, false).contains(EncounterType.PUFFER_SHOAL)); + // And it is booty-less by classification: nothing to farm + assertTrue(EncounterType.PUFFER_SHOAL.isNuisance()); } @Test @@ -82,8 +96,13 @@ void testMobsSpawnInTheirElement() { void testEveryEncounterMobIsActuallyHostile() { // The zombie nautilus taught this lesson: it is a tameable MOUNT // (AbstractNautilus extends Tameable, Vehicle), so it just swam away. - // Every encounter mob must be an Enemy or the encounter is scenery. + // Every encounter mob must be an Enemy or the encounter is scenery - + // except a declared NUISANCE, which is a hazard, not a hunter (the + // pufferfish stings on contact without ever being an Enemy). for (EncounterType type : EncounterType.values()) { + if (type.isNuisance()) { + continue; + } for (org.bukkit.entity.EntityType mob : type.getMobs()) { Class clazz = mob.getEntityClass(); assertTrue(clazz != null && org.bukkit.entity.Enemy.class.isAssignableFrom(clazz), diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/IntersticeMapTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/IntersticeMapTest.java new file mode 100644 index 0000000..88409f4 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/galaxy/IntersticeMapTest.java @@ -0,0 +1,115 @@ +package world.bentobox.tradewinds.galaxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +/** + * Tests the interstice feature map: pure seeded geometry for wart shoals and + * wither watchtowers - determinism, dome shape, and the open-water test that + * keeps strandings out of the masonry. + * + * @author tastybento + */ +class IntersticeMapTest { + + private static final long SEED = 987654L; + private static final int SEA = 70; + + private IntersticeMap map() { + return new IntersticeMap(SEED, 256, 0.5, 9, 0.2, 1536, 0.6); + } + + private IntersticeMap.Shoal firstShoal(IntersticeMap map) { + for (int cx = 0; cx < 50; cx++) { + Optional shoal = map.shoalInCell(cx, 3); + if (shoal.isPresent()) { + return shoal.get(); + } + } + throw new IllegalStateException("No shoal in 50 cells"); + } + + @Test + void testDeterminism() { + IntersticeMap a = map(); + IntersticeMap b = map(); + for (int cx = -20; cx < 20; cx++) { + assertEquals(a.shoalInCell(cx, cx), b.shoalInCell(cx, cx)); + assertEquals(a.towerInCell(cx, cx), b.towerInCell(cx, cx)); + } + // A different seed disagrees somewhere + IntersticeMap other = new IntersticeMap(SEED + 1, 256, 0.5, 9, 0.2, 1536, 0.6); + boolean differs = false; + for (int cx = -50; cx < 50 && !differs; cx++) { + differs = !a.shoalInCell(cx, 0).equals(other.shoalInCell(cx, 0)); + } + assertTrue(differs, "Two seeds should not lay the same shoals"); + } + + @Test + void testShoalDomeShape() { + IntersticeMap map = map(); + IntersticeMap.Shoal shoal = firstShoal(map); + // Crown: proud of the sea, plantable land + int crown = map.shoalSurfaceAt(shoal.centerX(), shoal.centerZ(), SEA).orElseThrow(); + assertTrue(crown > SEA + 1, "Crown should be dry land, got " + crown); + // Rim: the footprint's edge is submerged - wadable, not a cliff + int rim = map.shoalSurfaceAt(shoal.centerX() + shoal.radius(), shoal.centerZ(), SEA).orElseThrow(); + assertTrue(rim <= SEA, "Rim should be under water, got " + rim); + // Beyond the footprint: nothing + assertTrue(map.shoalSurfaceAt(shoal.centerX() + shoal.radius() * 3, shoal.centerZ(), SEA).isEmpty()); + } + + @Test + void testOpenWaterAvoidsFeatures() { + IntersticeMap map = map(); + IntersticeMap.Shoal shoal = firstShoal(map); + assertFalse(map.isOpenWater(shoal.centerX(), shoal.centerZ()), + "A shoal crown is not open water"); + // Somewhere provably empty: a cell that rolled no shoal (nor its + // neighbours), probed at its centre + + for (int cx = 0; cx < 50; cx++) { + if (map.shoalInCell(cx, 7).isEmpty() && map.shoalInCell(cx - 1, 7).isEmpty() + && map.shoalInCell(cx + 1, 7).isEmpty() && map.shoalInCell(cx, 6).isEmpty() + && map.shoalInCell(cx, 8).isEmpty()) { + int x = cx * 256 + 128; + int z = 7 * 256 + 128; + if (map.towersNear(x, z, 12).isEmpty()) { + assertTrue(map.isOpenWater(x, z), "Empty cells should be open water"); + return; + } + } + } + } + + @Test + void testZeroChanceDisables() { + IntersticeMap off = new IntersticeMap(SEED, 256, 0.0, 9, 0.2, 1536, 0.0); + for (int cx = 0; cx < 50; cx++) { + assertTrue(off.shoalInCell(cx, 0).isEmpty()); + assertTrue(off.towerInCell(cx, 0).isEmpty()); + } + assertTrue(off.isOpenWater(12345, -6789)); + } + + @Test + void testTowersExistAndSitInTheirCells() { + IntersticeMap map = map(); + boolean found = false; + for (int cx = 0; cx < 20 && !found; cx++) { + var tower = map.towerInCell(cx, 2); + if (tower.isPresent()) { + found = true; + // Jitter never leaves the cell + assertEquals(cx, Math.floorDiv(tower.get().centerX(), 1536)); + } + } + assertTrue(found, "No tower in 20 cells at 0.6 chance - wrong seed?"); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index 408aa9d..bff8dae 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -67,6 +67,10 @@ public void setUp() throws Exception { when(addon.getGalaxyEngine(org.mockito.ArgumentMatchers.anyLong())) .thenReturn(new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70, GalaxyConfig.defaultTypeWeights(), null))); + // Default: a featureless interstice (no shoals, no towers) - pure dark sea + when(addon.getIntersticeMap(org.mockito.ArgumentMatchers.anyLong())) + .thenReturn(new world.bentobox.tradewinds.galaxy.IntersticeMap(SEED, 256, 0.0, 9, 0.2, 1536, + 0.0)); } private WorldInfo worldInfo(Environment env, long seed) { @@ -144,8 +148,9 @@ void testOceanShape() { for (int z = 0; z < 16; z++) { // Bedrock at the bottom assertEquals(Material.BEDROCK, r.get(x, -64, z)); - // Solid stone base under the whole floor + // Solid stone base under the whole floor, deepslate below Y 0 assertEquals(Material.STONE, r.get(x, 0, z)); + assertEquals(Material.DEEPSLATE, r.get(x, -32, z)); // Water at sea level assertEquals(Material.WATER, r.get(x, settings.getSeaHeight(), z)); // Air above sea level @@ -200,6 +205,30 @@ void testIntersticeShape() { } } + @Test + void testWartShoalsBreakTheIntersticeSurface() { + // Shoals on at defaults: find one and generate its chunk + world.bentobox.tradewinds.galaxy.IntersticeMap map = new world.bentobox.tradewinds.galaxy.IntersticeMap( + SEED, 256, 0.5, 9, 0.2, 1536, 0.0); + when(addon.getIntersticeMap(org.mockito.ArgumentMatchers.anyLong())).thenReturn(map); + world.bentobox.tradewinds.galaxy.IntersticeMap.Shoal shoal = null; + for (int cx = 0; cx < 30 && shoal == null; cx++) { + shoal = map.shoalInCell(cx, 4).orElse(null); + } + assertTrue(shoal != null, "No shoal in 30 cells - wrong seed?"); + ChunkGeneratorWorld gen = new ChunkGeneratorWorld(addon); + RecordingChunkData r = generate(gen, Environment.NETHER, SEED, shoal.centerX() >> 4, + shoal.centerZ() >> 4); + int x = shoal.centerX() & 15; + int z = shoal.centerZ() & 15; + int sea = settings.getIntersticeSeaHeight(); + // The crown stands proud of the sea... + int top = floorTop(r, x, z, sea + 4); + assertTrue(top > sea, "Shoal crown should break the surface, top=" + top + " sea=" + sea); + // ...and it is soul sand - the one block nether wart plants on + assertEquals(Material.SOUL_SAND, r.get(x, top, z)); + } + @Test void testDeterminism() { // Same seed, same chunk -> identical blocks, across generator instances diff --git a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java index 90ff678..a7e2a58 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/GalaxyIslandRegistrarTest.java @@ -12,6 +12,7 @@ import java.util.HashMap; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import org.bukkit.Chunk; import org.bukkit.Location; @@ -20,11 +21,15 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import world.bentobox.bentobox.database.Database; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.lists.Flags; +import world.bentobox.bentobox.managers.IslandsManager; +import world.bentobox.bentobox.managers.island.IslandCache; import world.bentobox.tradewinds.CommonTestSetup; import world.bentobox.tradewinds.Settings; import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.WhiteBox; import world.bentobox.tradewinds.galaxy.GalaxyConfig; import world.bentobox.tradewinds.galaxy.GalaxyEngine; import world.bentobox.tradewinds.galaxy.IslandSpec; @@ -56,6 +61,14 @@ public void setUp() throws Exception { when(addon.getOverWorld()).thenReturn(world); when(addon.getIslands()).thenReturn(im); when(world.getSeed()).thenReturn(SEED); + // The range-box shrink re-grids through the cache and saves statically + IslandCache cache = mock(IslandCache.class); + when(cache.addIsland(any(Island.class))).thenReturn(true); + when(im.getIslandCache()).thenReturn(cache); + @SuppressWarnings("unchecked") + Database db = mock(Database.class); + when(db.saveObjectAsync(any())).thenReturn(CompletableFuture.completedFuture(true)); + WhiteBox.setInternalState(IslandsManager.class, "handler", db); registrar = new GalaxyIslandRegistrar(addon); spec = engine.islandInCell(0, 0).orElseThrow(); } @@ -82,6 +95,10 @@ void testRegistersIslandOnCenterChunkLoad() { assertEquals(spec.centerX() + 0.5, loc.getValue().getX()); assertEquals(spec.centerZ() + 0.5, loc.getValue().getZ()); verify(island).setName(spec.name()); + // The range box is the protection range, not the island distance - + // the full-distance box reserved ~1.1km around every port and no + // islet claim could ever fit near one + verify(island).setRange(new Settings().getIslandProtectionRange()); // Band policy: PvP, hostile spawning, villager protection Settings settings = new Settings(); verify(island).setSettingsFlag(Flags.PVP_OVERWORLD, diff --git a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java index cbf7198..a3371e3 100644 --- a/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java +++ b/src/test/java/world/bentobox/tradewinds/listeners/SpawnRespawnListenerTest.java @@ -47,6 +47,13 @@ public void setUp() throws Exception { // The spawn island's spawn point: the market plaza when(im.getSpawnPoint(world)).thenReturn(new Location(world, 72.5, 73, 72.5)); when(mockPlayer.getWorld()).thenReturn(world); + when(mockPlayer.isOnline()).thenReturn(true); + // The loaner is granted a tick after the respawn: run it inline + when(sch.runTask(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(Runnable.class))).thenAnswer(inv -> { + inv.getArgument(1, Runnable.class).run(); + return null; + }); listener = new SpawnRespawnListener(addon); deathBed = new Location(world, 500, 42, 500); } @@ -62,6 +69,21 @@ void testBoatlessRespawnerGetsTheLoaner() { org.mockito.Mockito.verify(boats).createFor(mockPlayer, org.bukkit.Material.BAMBOO_RAFT); } + @Test + void testIslandMembersKeepTheirIslandRespawn() { + // BentoBox's ISLAND_RESPAWN listener (NORMAL) already set the island + // home; this listener (HIGH) used to stomp it with the spawn plaza + // (playtest 2026-08-05). Island members must pass through untouched. + when(im.getIsland(world, uuid)).thenReturn(island); + Location islandHome = new Location(world, 45_000.5, 75, -8_000.5); + PlayerRespawnEvent event = new PlayerRespawnEvent(mockPlayer, islandHome, false, false); + listener.onRespawn(event); + assertEquals(islandHome, event.getRespawnLocation(), + "An island member's respawn must not be redirected to spawn"); + // The loaner still applies - their real boat is where they died + org.mockito.Mockito.verify(boats).createFor(mockPlayer, org.bukkit.Material.BAMBOO_RAFT); + } + @Test void testBoatWithinReachMeansNoLoaner() { // Their own boat is moored at the respawn point: no charity needed diff --git a/src/test/java/world/bentobox/tradewinds/travel/ChartLeaderboardTest.java b/src/test/java/world/bentobox/tradewinds/travel/ChartLeaderboardTest.java new file mode 100644 index 0000000..2936366 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/ChartLeaderboardTest.java @@ -0,0 +1,107 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.api.events.IslandChartedEvent; +import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; + +/** + * Tests the charted-islands leaderboard: event-driven updates and ordering. + * + * @author tastybento + */ +class ChartLeaderboardTest extends CommonTestSetup { + + private TradeWinds addon; + private PlayerDataManager pdm; + private ChartLeaderboard leaderboard; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + pdm = mock(PlayerDataManager.class); + when(addon.getPlayerDataManager()).thenReturn(pdm); + leaderboard = new ChartLeaderboard(addon); + } + + private UUID sailor(int charted) { + UUID id = UUID.randomUUID(); + TWPlayerData data = new TWPlayerData(id.toString()); + Set cells = IntStream.range(0, charted).mapToObj(i -> i + "," + i) + .collect(Collectors.toCollection(HashSet::new)); + data.setChartedIslands(cells); + when(pdm.get(id)).thenReturn(data); + leaderboard.onCharted(new IslandChartedEvent(id, null)); + return id; + } + + @Test + void testTopOrdersMostCharted() { + UUID low = sailor(3); + UUID high = sailor(40); + UUID mid = sailor(12); + var top = leaderboard.top(10); + assertEquals(3, top.size()); + assertEquals(high, top.get(0).playerId()); + assertEquals(40, top.get(0).charted()); + assertEquals(mid, top.get(1).playerId()); + assertEquals(low, top.get(2).playerId()); + } + + @Test + void testTopIsCapped() { + for (int i = 0; i < 15; i++) { + sailor(i + 1); + } + assertEquals(10, leaderboard.top(10).size()); + // Best first, and the cap keeps the best, not the first-seen + assertEquals(15, leaderboard.top(10).get(0).charted()); + assertEquals(6, leaderboard.top(10).get(9).charted()); + } + + @Test + void testRechartingUpdatesInPlace() { + UUID id = sailor(4); + // The same sailor charts more: count replaces, no duplicate row + TWPlayerData data = new TWPlayerData(id.toString()); + data.setChartedIslands(Set.of("9,9", "8,8", "7,7", "6,6", "5,5")); + when(pdm.get(id)).thenReturn(data); + leaderboard.onCharted(new IslandChartedEvent(id, null)); + var top = leaderboard.top(10); + assertEquals(1, top.size()); + assertEquals(5, top.get(0).charted()); + } + + @Test + void testEmptyBoard() { + assertTrue(leaderboard.top(10).isEmpty()); + } + + @Test + void testPlaceholderRegistration() { + RankService ranks = mock(RankService.class); + when(addon.getPlugin()).thenReturn(plugin); + leaderboard.registerPlaceholders(ranks); + // rank + charted + 10 names + 10 counts + org.mockito.Mockito.verify(phm, org.mockito.Mockito.times(22)) + .registerPlaceholder(any(TradeWinds.class), any(String.class), any()); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/HomeWarpTest.java b/src/test/java/world/bentobox/tradewinds/travel/HomeWarpTest.java new file mode 100644 index 0000000..ae9000c --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/HomeWarpTest.java @@ -0,0 +1,128 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.api.metadata.MetaDataValue; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.Settings; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.IslandSpec; +import world.bentobox.tradewinds.galaxy.RouteGraph; + +/** + * The member-only warp node (Stage 7b): a claimed islet travels as a + * synthetic home spec, pinned first in its members' warp dialogs and + * invisible to everyone else. + * + * @author tastybento + */ +class HomeWarpTest extends CommonTestSetup { + + private static final long SEED = 77777L; + + private TradeWinds addon; + private Settings settings; + private WarpService service; + private GalaxyEngine engine; + private IslandSpec origin; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + when(addon.getOverWorld()).thenReturn(world); + when(world.getSeed()).thenReturn(SEED); + engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 1.0, 0, 5000, 70)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); + when(addon.getRouteGraph()).thenReturn(new RouteGraph(0.01, Map.of())); + when(addon.getIslands()).thenReturn(im); + origin = engine.islandInCell(0, 0).orElseThrow(); + + // The player has charted two neighbouring islands + PlayerDataManager pdm = mock(PlayerDataManager.class); + when(addon.getPlayerDataManager()).thenReturn(pdm); + TWPlayerData data = new TWPlayerData(uuid.toString()); + data.setChartedIslands(Set.of("1,0", "0,1")); + when(pdm.get(uuid)).thenReturn(data); + + service = new WarpService(addon); + } + + /** Give the player a claimed island at the given centre. */ + private void claimAt(int x, int z) { + when(im.getIsland(world, uuid)).thenReturn(island); + when(island.getMetaData(anyString())) + .thenReturn(Optional.of(new MetaDataValue(x / 900 + "," + z / 900 + ",80"))); + org.bukkit.Location center = mock(org.bukkit.Location.class); + when(center.getBlockX()).thenReturn(x); + when(center.getBlockZ()).thenReturn(z); + when(island.getCenter()).thenReturn(center); + when(island.getProtectionRange()).thenReturn(112); + } + + @Test + void testHomeSpecIsSyntheticAndMarked() { + claimAt(45_000, -8_000); + IslandSpec home = HomePort.specFor(addon, User.getInstance(mockPlayer)).orElseThrow(); + assertTrue(HomePort.isHome(home)); + assertEquals(45_000, home.centerX()); + assertEquals(-8_000, home.centerZ()); + assertEquals(Math.floorDiv(45_000, 900), home.cellX()); + assertEquals(Math.floorDiv(-8_000, 900), home.cellZ()); + // No admin name: the locale's "Home" (the mock returns the key) + assertEquals("tradewinds.home.port-name", home.name()); + } + + @Test + void testHomeIsPinnedFirstForMembers() { + claimAt(45_000, -8_000); + var destinations = service.destinations(mockPlayer, origin, 1_000_000); + assertFalse(destinations.isEmpty()); + assertTrue(HomePort.isHome(destinations.get(0).island()), "Home should be pinned first"); + // And exactly once - the ports follow + assertEquals(1, destinations.stream().filter(d -> HomePort.isHome(d.island())).count()); + assertTrue(destinations.size() >= 3, "Charted ports should still be offered"); + } + + @Test + void testNoClaimMeansNoHomeEntry() { + // No island at all + var destinations = service.destinations(mockPlayer, origin, 1_000_000); + assertTrue(destinations.stream().noneMatch(d -> HomePort.isHome(d.island()))); + // An island the player merely visits (no claim metadata) adds nothing + when(im.getIsland(world, uuid)).thenReturn(island); + when(island.getMetaData(anyString())).thenReturn(Optional.empty()); + destinations = service.destinations(mockPlayer, origin, 1_000_000); + assertTrue(destinations.stream().noneMatch(d -> HomePort.isHome(d.island()))); + } + + @Test + void testHomeCostsNormalFuel() { + claimAt(45_000, -8_000); + var home = service.destinations(mockPlayer, origin, 1_000_000).get(0); + long dist = Math.round(Math.sqrt(home.island().distanceSquared(origin.centerX(), origin.centerZ()))); + assertEquals((int) Math.ceil(dist * 0.01), home.fuelCost(), 1.0, + "Home warp pays the same fuel-per-block as any port"); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/IsletClaimServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/IsletClaimServiceTest.java new file mode 100644 index 0000000..e416aa5 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/IsletClaimServiceTest.java @@ -0,0 +1,208 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +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 java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.Database; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.hooks.VaultHook; +import world.bentobox.bentobox.managers.IslandsManager; +import world.bentobox.bentobox.managers.island.IslandCache; +import world.bentobox.tradewinds.CommonTestSetup; +import world.bentobox.tradewinds.Settings; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.WhiteBox; +import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; +import world.bentobox.tradewinds.galaxy.GalaxyConfig; +import world.bentobox.tradewinds.galaxy.GalaxyEngine; +import world.bentobox.tradewinds.galaxy.Islet; + +/** + * Tests islet claiming end to end against the real rank service and a real + * galaxy engine - only BentoBox's island grid and the economy are mocked. + * + * @author tastybento + */ +class IsletClaimServiceTest extends CommonTestSetup { + + private static final long SEED = 424242L; + + private TradeWinds addon; + private Settings settings; + private PlayerDataManager pdm; + private IslandCache cache; + private VaultHook vault; + private IsletClaimService service; + private GalaxyEngine engine; + private Islet islet; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + when(addon.getPlugin()).thenReturn(plugin); + when(addon.getOverWorld()).thenReturn(world); + when(world.getSeed()).thenReturn(SEED); + when(world.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(75); + + // Empty galaxy (no trading islands), wild islets on at defaults + engine = new GalaxyEngine(new GalaxyConfig(SEED, 2500, 160, 45, 0.0, 0, 5000, 70, + GalaxyConfig.defaultTypeWeights(), null, 0.55, 75, 900)); + when(addon.getGalaxyEngine(anyLong())).thenReturn(engine); + + // Find a wild islet to stand on + islet = findIslet(); + when(location.getBlockX()).thenReturn(islet.centerX()); + when(location.getBlockZ()).thenReturn(islet.centerZ()); + + // BentoBox island grid + cache = mock(IslandCache.class); + when(cache.addIsland(any(Island.class))).thenReturn(true); + when(addon.getIslands()).thenReturn(im); + when(im.getIslandCache()).thenReturn(cache); + when(im.getIslandAt(any())).thenReturn(Optional.empty()); + WhiteBox.setInternalState(IslandsManager.class, "handler", islandDb()); + + // A named addon, for the island id prefix + AddonDescription desc = new AddonDescription.Builder("world.bentobox.tradewinds", "TradeWinds", "1.0") + .build(); + when(addon.getDescription()).thenReturn(desc); + + // Economy: rich enough by default + vault = mock(VaultHook.class); + when(plugin.getVault()).thenReturn(Optional.of(vault)); + when(vault.getBalance(any(User.class))).thenReturn(200_000.0); + + // A well-travelled claimer by default + pdm = mock(PlayerDataManager.class); + when(addon.getPlayerDataManager()).thenReturn(pdm); + chart(40); + + service = new IsletClaimService(addon, new RankService(addon)); + } + + @SuppressWarnings("unchecked") + private Database islandDb() { + Database db = mock(Database.class); + when(db.saveObjectAsync(any())).thenReturn(CompletableFuture.completedFuture(true)); + return db; + } + + private Islet findIslet() { + for (int cx = 0; cx < 50; cx++) { + Optional found = engine.wildIsletInCell(cx, 7); + if (found.isPresent()) { + return found.get(); + } + } + throw new IllegalStateException("No islet in 50 cells - wrong seed?"); + } + + private void chart(int count) { + TWPlayerData data = new TWPlayerData(uuid.toString()); + Set cells = IntStream.range(0, count).mapToObj(i -> i + "," + i) + .collect(Collectors.toCollection(HashSet::new)); + data.setChartedIslands(cells); + when(pdm.get(uuid)).thenReturn(data); + } + + @Test + void testClaimSucceeds() { + IsletClaimService.Result result = service.claim(mockPlayer); + assertTrue(result.success(), "Expected success, got " + result.refusal()); + assertNotNull(result.island()); + // Sized to the islet, not the island distance + int expected = islet.radius() + settings.getClaimProtectionMargin(); + assertEquals(expected, result.island().getProtectionRange()); + assertEquals(expected, result.island().getRange()); + assertEquals(uuid, result.island().getOwner()); + assertTrue(result.island().getUniqueId().startsWith("TradeWinds")); + // Marked as a claim, for the Stage 7b warp node + assertTrue(result.island().getMetaData(IsletClaimService.META_CLAIM).isPresent()); + // Paid in full, once + verify(vault).withdraw(any(User.class), org.mockito.ArgumentMatchers.eq(150_000.0)); + } + + @Test + void testRefusesOffIslet() { + // A wild-islet cell corner is never inside an islet: centers keep at + // least a quarter-cell from the edge and the radius cannot reach it + when(location.getBlockX()).thenReturn(900 * 20); + when(location.getBlockZ()).thenReturn(900 * 20); + IsletClaimService.Result result = service.claim(mockPlayer); + assertEquals(IsletClaimService.Refusal.NOT_ON_ISLET, result.refusal()); + } + + @Test + void testRefusesWhenAlreadyInAnIsland() { + when(im.getIsland(world, uuid)).thenReturn(island); + IsletClaimService.Result result = service.claim(mockPlayer); + assertEquals(IsletClaimService.Refusal.HAS_ISLAND, result.refusal()); + } + + @Test + void testRefusesAClaimedIslet() { + when(im.getIslandAt(any())).thenReturn(Optional.of(island)); // owned mock island + IsletClaimService.Result result = service.claim(mockPlayer); + assertEquals(IsletClaimService.Refusal.ALREADY_CLAIMED, result.refusal()); + } + + @Test + void testRefusesLowRank() { + chart(35); // one short of Tide Captain + IsletClaimService.Result result = service.claim(mockPlayer); + assertEquals(IsletClaimService.Refusal.RANK_TOO_LOW, result.refusal()); + verify(vault, never()).withdraw(any(), any(Double.class)); + } + + @Test + void testRefusesPoverty() { + when(vault.getBalance(any(User.class))).thenReturn(149_999.0); + IsletClaimService.Result result = service.claim(mockPlayer); + assertEquals(IsletClaimService.Refusal.CANNOT_AFFORD, result.refusal()); + verify(vault, never()).withdraw(any(), any(Double.class)); + } + + @Test + void testRefusesWithoutEconomy() { + when(plugin.getVault()).thenReturn(Optional.empty()); + IsletClaimService.Result result = service.claim(mockPlayer); + assertEquals(IsletClaimService.Refusal.NO_ECONOMY, result.refusal()); + } + + @Test + void testGridRefusalCostsNothing() { + when(cache.addIsland(any(Island.class))).thenReturn(false); + IsletClaimService.Result result = service.claim(mockPlayer); + assertEquals(IsletClaimService.Refusal.WATERS_TAKEN, result.refusal()); + assertFalse(result.success()); + // The grid said no AFTER the checks - but the money never moved + verify(vault, never()).withdraw(any(), any(Double.class)); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/RankServiceTest.java b/src/test/java/world/bentobox/tradewinds/travel/RankServiceTest.java new file mode 100644 index 0000000..99df68b --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/RankServiceTest.java @@ -0,0 +1,134 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Set; +import java.util.UUID; + +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.TradeWinds; +import world.bentobox.tradewinds.dataobjects.PlayerDataManager; +import world.bentobox.tradewinds.dataobjects.TWPlayerData; + +/** + * Tests the Seafarer rank ladder: thresholds, boundaries, the next rung, and + * the fail-closed claim gate. + * + * @author tastybento + */ +class RankServiceTest extends CommonTestSetup { + + private TradeWinds addon; + private Settings settings; + private RankService service; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + addon = mock(TradeWinds.class); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + service = new RankService(addon); + } + + @Test + void testLadderBoundaries() { + // The starter cluster pre-charts 5 - still a Deck Hand + assertEquals("deck-hand", service.rankFor(0).slug()); + assertEquals("deck-hand", service.rankFor(5).slug()); + // The first self-charted island is the first promotion + assertEquals("cabin-scout", service.rankFor(6).slug()); + assertEquals("cabin-scout", service.rankFor(7).slug()); + // Exact thresholds promote + assertEquals("tide-captain", service.rankFor(36).slug()); + assertEquals("archipelago-ace", service.rankFor(35).slug()); + // Nothing above the top + assertEquals("mythic-mariner", service.rankFor(100).slug()); + assertEquals("mythic-mariner", service.rankFor(5000).slug()); + } + + @Test + void testNextRung() { + assertEquals("cabin-scout", service.next(0).orElseThrow().slug()); + assertEquals("cabin-scout", service.next(5).orElseThrow().slug()); + assertEquals("coast-finder", service.next(6).orElseThrow().slug()); + assertEquals("mythic-mariner", service.next(99).orElseThrow().slug()); + assertTrue(service.next(100).isEmpty()); + } + + @Test + void testLocaleKey() { + assertEquals("tradewinds.rank.deck-hand", service.rankFor(0).localeKey()); + } + + @Test + void testClaimThresholdDefault() { + // Default gate: Tide Captain, 36 charted + assertEquals(36, service.claimThreshold()); + } + + @Test + void testClaimThresholdFailsClosedOnTypo() { + settings.setClaimMinimumRank("tide-kaptain"); + // A slug that names no rung requires the TOP rank, not none + assertEquals(100, service.claimThreshold()); + verify(addon).logError(any(String.class)); + } + + @Test + void testChartedCountReadsPlayerData() { + TWPlayerData data = sailorWith(Set.of("0,0", "1,1", "2,2")); + assertEquals(3, service.chartedCount(uuid)); + assertEquals(0, data.getChartedBonus()); + } + + @Test + void testAdminPromotionStoresTheDifference() { + TWPlayerData data = sailorWith(Set.of("0,0", "1,1", "2,2")); + // Make them a Tide Captain for claim testing + service.setEffectiveCharted(uuid, 36); + assertEquals(33, data.getChartedBonus()); + assertEquals(36, service.chartedCount(uuid)); + assertEquals("tide-captain", service.rankFor(service.chartedCount(uuid)).slug()); + // Real charting keeps counting on top of the adjustment + data.getChartedIslands().add("5,5"); + assertEquals(37, service.chartedCount(uuid)); + } + + @Test + void testAdminDemotionClampsAtZero() { + TWPlayerData data = sailorWith(new java.util.HashSet<>(Set.of("0,0", "1,1", "2,2"))); + service.setEffectiveCharted(uuid, 0); + assertEquals(-3, data.getChartedBonus()); + assertEquals(0, service.chartedCount(uuid)); + assertEquals("deck-hand", service.rankFor(service.chartedCount(uuid)).slug()); + } + + @Test + void testResetReturnsToTheRealChart() { + TWPlayerData data = sailorWith(Set.of("0,0", "1,1", "2,2")); + service.setEffectiveCharted(uuid, 100); + service.clearAdjustment(uuid); + assertEquals(0, data.getChartedBonus()); + assertEquals(3, service.chartedCount(uuid)); + } + + private TWPlayerData sailorWith(Set cells) { + PlayerDataManager pdm = mock(PlayerDataManager.class); + when(addon.getPlayerDataManager()).thenReturn(pdm); + TWPlayerData data = new TWPlayerData(uuid.toString()); + data.setChartedIslands(new java.util.HashSet<>(cells)); + when(pdm.get(any(UUID.class))).thenReturn(data); + return data; + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/RefitMooredTest.java b/src/test/java/world/bentobox/tradewinds/travel/RefitMooredTest.java new file mode 100644 index 0000000..018adc9 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/RefitMooredTest.java @@ -0,0 +1,183 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +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 java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Boat; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Item; +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 moored-refit bug (playtest 2026-08-04): buy a bigger hull with your + * boat left at the dock, and the record upgraded while the old hull kept + * floating - the swap searched a 6-block box around the LAST-REMEMBERED + * position, and dismounted boats glide, drift and go stale. The fix finds + * the placed avatar by IDENTITY among the world's loaded entities. + * + * @author tastybento + */ +class RefitMooredTest extends CommonTestSetup { + + private static final String WORLD_NAME = "tradewinds_world"; + + private TradeWinds addon; + private TestHolds holds; + private BoatService service; + private BoatHold hold; + + @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(world.getName()).thenReturn(WORLD_NAME); + mockedBukkit.when(() -> Bukkit.getWorld(WORLD_NAME)).thenReturn(world); + + hold = holds.giveBoat(uuid, Material.OAK_BOAT); + // Last seen at the dock - deliberately NOWHERE NEAR where the boat + // actually floats now, which is what broke the old box search + hold.setWorld(WORLD_NAME); + hold.setX(100); + hold.setY(70); + hold.setZ(100); + } + + /** A boat entity stamped with the hold's id, floating wherever it likes. */ + private Boat mooredBoat(int x, int z) { + Boat boat = mock(Boat.class); + PersistentDataContainer pdc = mock(PersistentDataContainer.class); + when(pdc.get(BoatService.BOAT_ID_KEY, PersistentDataType.STRING)).thenReturn(hold.getUniqueId()); + when(boat.getPersistentDataContainer()).thenReturn(pdc); + Location at = mock(Location.class); + when(at.getWorld()).thenReturn(world); + when(at.getBlockX()).thenReturn(x); + when(at.getBlockY()).thenReturn(70); + when(at.getBlockZ()).thenReturn(z); + when(boat.getLocation()).thenReturn(at); + when(boat.getPassengers()).thenReturn(List.of()); + return boat; + } + + @Test + void testMooredRefitSwapsTheDriftedHull() { + // The boat drifted 40 blocks from where the chart last saw it + Boat old = mooredBoat(140, 100); + when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(old)); + Boat fresh = mooredBoat(140, 100); + when(world.spawnEntity(any(Location.class), eq(EntityType.SPRUCE_BOAT))).thenReturn(fresh); + // Not riding, not carrying: the pack is empty + when(inv.getContents()).thenReturn(new ItemStack[0]); + + service.refit(mockPlayer, hold, Material.SPRUCE_BOAT); + + assertEquals("SPRUCE_BOAT", hold.getMaterial()); + verify(old).remove(); + verify(world).spawnEntity(any(Location.class), eq(EntityType.SPRUCE_BOAT)); + // The chart is honest again: position re-remembered from the entity + assertEquals(140, hold.getX()); + } + + @Test + void testDroppedItemHullIsRefitInPlace() { + when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of()); + // The hull lies on the sand as a dropped item + 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(hold.getUniqueId()); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(stack.hasItemMeta()).thenReturn(true); + when(stack.getItemMeta()).thenReturn(meta); + Item item = mock(Item.class); + when(item.getItemStack()).thenReturn(stack); + Location at = mock(Location.class); + when(at.getWorld()).thenReturn(world); + when(item.getLocation()).thenReturn(at); + when(world.getEntitiesByClass(Item.class)).thenReturn(List.of(item)); + when(inv.getContents()).thenReturn(new ItemStack[0]); + + service.refit(mockPlayer, hold, Material.SPRUCE_BOAT); + + assertEquals("SPRUCE_BOAT", hold.getMaterial()); + verify(stack).setType(Material.SPRUCE_BOAT); + verify(item).setItemStack(stack); + verify(world, never()).spawnEntity(any(), any(EntityType.class)); + } + + @Test + void testFindPlacedIgnoresStrangersAndFindsByIdentity() { + Boat stranger = mock(Boat.class); + PersistentDataContainer none = mock(PersistentDataContainer.class); + when(stranger.getPersistentDataContainer()).thenReturn(none); + Boat mine = mooredBoat(5000, -3000); // half a sea away from last-seen + when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(stranger, mine)); + + assertTrue(service.findPlaced(hold).isPresent()); + assertEquals(mine, service.findPlaced(hold).orElseThrow()); + // And looking healed the chart + assertEquals(5000, hold.getX()); + assertEquals(-3000, hold.getZ()); + } + + @Test + void testNothingLoadedMeansNothingFound() { + when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of()); + when(world.getEntitiesByClass(Item.class)).thenReturn(List.of()); + assertTrue(service.findPlaced(hold).isEmpty()); + // The refit still updates the record and survives without a hull + when(inv.getContents()).thenReturn(new ItemStack[0]); + service.refit(mockPlayer, hold, Material.SPRUCE_BOAT); + assertEquals("SPRUCE_BOAT", hold.getMaterial()); + verify(world, never()).spawnEntity(any(), any(EntityType.class)); + } + + @Test + void testCarriedHullStillWinsOverTheWorldSearch() { + // In the pack: the boat is wherever the sailor is - the world search + // must not even run, or a stamped stranger could shadow the carried one + ItemStack carried = mock(ItemStack.class); + ItemMeta meta = mock(ItemMeta.class); + PersistentDataContainer pdc = mock(PersistentDataContainer.class); + when(pdc.get(BoatService.BOAT_ID_KEY, PersistentDataType.STRING)).thenReturn(hold.getUniqueId()); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(carried.hasItemMeta()).thenReturn(true); + when(carried.getItemMeta()).thenReturn(meta); + when(inv.getContents()).thenReturn(new ItemStack[] { carried }); + + service.refit(mockPlayer, hold, Material.SPRUCE_BOAT); + + verify(carried).setType(Material.SPRUCE_BOAT); + verify(world, never()).getEntitiesByClass(Boat.class); + } +} diff --git a/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java b/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java index 0479a6c..0c5c9ee 100644 --- a/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java +++ b/src/test/java/world/bentobox/tradewinds/travel/WarpSelectionTest.java @@ -52,6 +52,8 @@ public void setUp() throws Exception { when(addon.getOverWorld()).thenReturn(world); when(world.getSeed()).thenReturn(SEED); when(addon.getRouteGraph()).thenReturn(new RouteGraph(0.01, Map.of())); + // No claimed island: the home pin (Stage 7b) stays out of these tests + when(addon.getIslands()).thenReturn(im); PlayerDataManager pdm = mock(PlayerDataManager.class); when(addon.getPlayerDataManager()).thenReturn(pdm); data = new TWPlayerData(uuid.toString()); diff --git a/tradewinds-dev-plan.md b/tradewinds-dev-plan.md index a55d180..f9cf279 100644 --- a/tradewinds-dev-plan.md +++ b/tradewinds-dev-plan.md @@ -76,12 +76,55 @@ Each stage produces a testable, playable increment. Stages are sized so a stage **Accept:** each rep transition triggers the right consequences; smuggler chase (scan → flee/fight/jettison) plays out correctly; police never drop loot and never leak entities; bounty pays exactly once. -## Stage 7 — Player islands -**Goal:** ownership. Largely existing BentoBox machinery. - -- Purchase command: sufficient balance → choose island blueprint → placement at a valid random position respecting minimum separation from all islands; standard island/team management, protection, and home warps thereafter. - -**Accept:** purchased islands appear in open ocean, never overlapping any range; AcidIsland-equivalent island life works. +## Stage 7 — Player islands: wild-islet claiming +**Goal:** ownership and faction bases. Decided 2026-08-04: players claim +**existing wild islets** — no blueprints, no pasting, no random placement. The +land is already there; claiming registers it. Islets never overlap trading +islands (generator clearance), and any band is claimable. First claimed, first +owned: pillaged, wrecked or built-on makes no difference. Two sessions. + +**7a — Seafarer ranks & the claim:** +- **Rank service**: rank = charted-island count against a config threshold + table; names in the locale (`tradewinds.rank.`). Default table + (bottom lifted above the pre-charted starter cluster of 5): Deck Hand 0, + Cabin Scout 6, Coast Finder 8, Bay Explorer 10, Island Hopper 12, Reef + Runner 15, Wave Master 17, Chart Maker 20, Storm Navigator 25, Archipelago + Ace 30, Tide Captain 36, Horizon Hunter 42, Sea Sentinel 49, Salty + Commodore 56, Ocean Overlord 64, Pirate Pathfinder 72, Grand Admiral 81, + Globe Voyager 90, Mythic Mariner 100. (Calibration: ~36 ≈ explored past + Frontier, ~100 ≈ deep Anarchic.) +- **Leaderboard + placeholders**: cached top-10 by charted count, updated on + chart events; PlaceholderAPI placeholders for own rank and the top list. + (Money cannot rank — Vault has no top-N and Bank hides value.) +- **Claim command**: standing on an unclaimed wild islet, rank ≥ config gate + (default Tide Captain) and balance ≥ config price (default 150,000 — above + the Pale Oak Chest Boat's 110,250, so boats come first). Registers a real + BentoBox island centred on the islet, protection range from islet radius + + margin, default flags (players and admins customise after). Bank and team + machinery attach on their own; max team size is BentoBox perms — admin turf. +- **Respawn**: BentoBox respawn-on-island default; islandless players respawn + at the spawn port. + +**7b — Living with a base:** (BUILT 2026-08-05 — see PROGRESS) +- **`/tw go` shows the way home, never takes you.** Toggle: while on, a + persistent homing indicator (chart-hologram home marker + nav bar course) — + coarse 8-point direction by day, exact bearing and distance by night (read + the stars). Home renders on the Star Chart for all members. +- **Member-only warp node**: the claimed islet joins its members' warp + dialogs (pinned entry, normal fuel cost); warping OUT triggers at the + claimed island's protection border like a port's ring. Non-members never + see it. No teleporting anywhere — the warp is the only fast route, exactly + as between ports. +- **Unclaim**: owner-only command, admin-unregister semantics — blocks stay + exactly as left, islet becomes claimable again, no refund. Refuses while + the team has members: kicking everyone out first is the intended pain. +- **Ship's compass** on the outfitter shelf: vanilla lodestone-bound compass + to the base. Physical, lossable, giftable. + +**Accept:** claim gate enforces rank and price; claimed islet protects and +manages like an AcidIsland island; members (and only members) can warp to and +from it on normal fuel; `/tw go` guides without teleporting; unclaim leaves +every block and reopens the claim; rank names all come from the locale. ## Stage 7.5 — Salvage economy & free trading Scavengers, farmers and pirates need a market; the hold needs to carry NBT and diff --git a/tradewinds-interstice-plan.md b/tradewinds-interstice-plan.md new file mode 100644 index 0000000..0c2dd98 --- /dev/null +++ b/tradewinds-interstice-plan.md @@ -0,0 +1,156 @@ +# TradeWinds — Interstice Resources Plan + +*Drafted 2026-08-05 from design discussion with Ben. Normative for nether-item +sourcing and portal behaviour. Status: **BUILT** (same day) — N1, N2 and the +booty half of N3 shipped; deferred: magma cubes, secondhand-shelf seeding, +price spot-checks after play. See PROGRESS.md for implementation notes.* + +## Goal + +Nether-exclusive items must exist so beacons, potions and the rest of +late-game crafting are reachable — without an End world (never), without +breaking "entry to the interstice is by accident", and without new economy +plumbing (every target item already has a base price; the salvage loop +absorbs everything). + +The interstice stays what it is — a hostile dark sea you are dropped into — +but a dropped sailor now has a reason to look around before taking the free +re-engage home. **Warp failure changes from punishment to opportunity.** + +## Entry model (decided) + +**Consolation-prize only.** Resources are reachable only when a failed warp +puts you there. The owed re-engage never expires, so you may linger, gather +and fight as long as you dare, then take the free jump. No deliberate-entry +mechanic ships with this plan; see Portals below for the gated future option. + +Knock-on to check at implementation: `/twadmin warpfail` becomes the admin +door for testing all of this, and players will discover that Anarchic-band +warps (highest failure chance) are the "route" to nether goods — that is +intended: the most lawless water feeds the most dangerous market. + +## Item coverage + +| Item | Needed for | Source (this plan) | +|---|---|---| +| Nether wart | every real potion | wart shoals (1) | +| Blaze rod / powder | brewing stand, fuel, strength, magma cream | blaze pickets (2) | +| Ghast tear | regeneration | already drops — interstice ghasts are not police | +| Glowstone dust | amplifier II, lighting | ceiling clusters (3) | +| Quartz | comparators, observers, building | spire seams (4) | +| Magma cream | fire resistance | slime + blaze powder craft; magma cubes (6) | +| Soul sand / soul soil | wither, soul fire | already the interstice floor | +| Wither skeleton skull | nether star → beacon | wither watchtowers (5) | +| Netherrack / nether bricks | building, watchtower salvage | interstice crust (already); towers (5) | +| Crimson/warped wood, shroomlight | building | groves on grand shoals (1) | +| Netherite scrap / templates | netherite gear | LOOT ONLY: tower chests, encounter booty, secondhand shelves — never mineable | +| Dragon's breath, ender pearls, shulker shells, echo shards | lingering potions, endgame | TRADE ONLY: "goods from beyond the horizon" — rare secondhand/booty items, never gatherable. No End, ever. | + +Already covered in the overworld and unchanged: redstone, gunpowder, spider +eye, sugar, rabbit's foot, pufferfish, golden carrot, phantom membrane +(insomnia phantoms), glass, obsidian. + +## Sources + +All seeded, deterministic, pure functions of (seed, position) in the +`galaxy`-package style — no Bukkit imports in the geometry, unit-tested +headlessly. Numbered for reference; suggested phases at the end. + +1. **Wart shoals.** Small soul-sand banks breaking the interstice surface — + the interstice's wild islets, drab and low. Nether wart grows on top; + harvest and replant like any crop. A rare **grand shoal** (config chance) + carries a crimson or warped grove: stems, wart blocks, shroomlights. + - Config: `interstice.shoal-chance`, `shoal-grid`, `shoal-radius`, + `grand-shoal-chance`. +2. **Blaze pickets.** A config fraction of the existing braziers grow a + nether-brick turret holding a **blaze spawner**. Renewable rods, fought + from a boat, contained by water on every side. + - Config: `interstice.blaze-picket-chance` (fraction of braziers). +3. **Glowstone ceiling clusters.** Seeded glowstone growths hanging from the + interstice's netherrack lid — visible for hundreds of blocks, so light + becomes navigation and destination at once. Mining them is boat-ladder + engineering, which is the right price. + - Config: `interstice.glowstone-cluster-chance` (per ceiling chunk), + `glowstone-cluster-size`. +4. **Quartz spire seams.** The basalt outcrops and rift walls carry quartz + ore (and magma blocks). The flat seabed stays barren — "nothing worth + mining" survives where it matters; the value stands up where you can see + it. + - Config: `interstice.spire-quartz-chance`. +5. **Wither watchtowers.** Rare nether-brick ruins standing out of the sea — + the interstice's only structure. Wither skeletons spawn within the walls + (spawner or structure-bounded natural spawns — decide at implementation; + spawner is simpler and police-rule-proof). A loot chest may hold netherite + scrap or a smithing template (config table). Three skulls plus floor soul + sand = a wither summoned over open water, far from every build — the best + possible arena for it. + - Config: `interstice.watchtower-grid`, `watchtower-chance`, + `watchtower-loot-table`. +6. **Magma cubes.** Natural spawns on the basalt around braziers (small + sizes; the craft in (2) already covers fire resistance, so this is + texture, not necessity). + +**Explicit non-sources:** no ancient debris anywhere ("loot has a +destination, which is a route" — mineable debris collapses the netherite +trade); no piglin bartering (cut for scope; revisit only if a use appears); +no End portals, stronghold, or elytra, ever. + +## Nether portals (decided 2026-08-05) + +Reality check from play: ruined portals are common ocean structures (three +found in one session), so players WILL complete and light them, and a silent +non-answer reads as a bug. + +- **Lighting a portal: refused, with lore.** The flint-and-steel moment gets + a message — *"the sea between seas does not open to fire and stone"* — so + the refusal is a world rule, not a missing feature. (Today the seal is + silent: `IntersticePortalListener` + `make-nether-portals: false`.) +- **Ruined portals stay what they are: salvage.** Obsidian, crying obsidian, + the gold loot — cargo for the hold, nothing more. +- **Rejected: portal as free/paid warp terminal to trading posts.** It + bypasses the boat, the fuel economy and the border-crossing ritual — the + three things that make travel the game. If a shore-side warp is ever + wanted, it must price exactly like the border warp (boat at island, fuel + from the hold), at which point it adds nothing the border does not. +- **Backlog (Stage 8, config-gated OFF): the portal as deliberate entry.** + If deliberate interstice entry is ever wanted, the player-built portal is + the natural door: expensive to build (obsidian is priced), one-way IN + (arrive in the interstice near the scaled position, boat alongside like a + warp arrival), exit only by the standard free re-engage. Gate: + `interstice.portal-entry`, default false. This preserves + entry-by-accident as the shipped default while giving the portal a real + answer if a server wants it. + +## Economy notes + +- Everything gatherable above already has a base price + (`economy.base-prices`) — wart, rods, tears, glowstone dust, quartz, magma + cream, soul sand, netherrack. No new price plumbing. +- Watch two prices at implementation: NETHER_WART (30) and BLAZE_ROD (200) + become *farmable* — the drift/absorption model already damps dumping, but + spot-check a wart-farm dump against a TL5+ port. +- Nether goods are natural additions to the encounter booty table and the + secondhand shelves at high-tech ports, which keeps the trade route open + for players who never fail a warp. + +## Suggested phases + +- **N1 — gathering:** wart shoals + glowstone ceiling + quartz seams + the + portal lighting message. (All generation + one listener message; no new + mobs.) Clean-slate note applies: interstice world folder must be deleted. +- **N2 — fighting:** blaze pickets + magma cubes + wither watchtowers + + loot tables. (Spawners, structure spawns, loot.) +- **N3 — polish:** grand-shoal groves, booty-table additions, secondhand + seeding, price spot-checks. + +## Testing notes (for TESTING.md when built) + +- A rigged warp failure lands you within sight of at least one resource + (shoal density should make this near-certain; if not, the consolation + prize is a lie). +- Re-engage still free and offered after an hour of gathering. +- Blazes/wither skeletons never leak beyond their pickets/towers; police + no-drop rule untouched (these are not police). +- Lighting a completed ruined portal prints the lore message and does NOT + open a portal, in both worlds. +- Clean slate: regenerate `tradewinds_world_nether` after N1. From af5ab5b929e23361edbb128efb309f72270dfb8f Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 6 Aug 2026 19:56:09 -0700 Subject: [PATCH 095/112] Answer "where did my boat go?" - four guards and a logbook A playtest death 4,400 blocks from home turned into region-file archaeology: the chart pointed the OLD BOAT marker at an empty dock for two days while the whole death kit sat frozen in an unloaded chunk, with TWO copies of the hull item in it. Four causes, four fixes, all pinned by BoatArchaeologyTest: - Death drops re-remember every stamped hull at the wreck site (manual drops always did; death bypasses PlayerDropItemEvent). No TTL starts: death drops freeze in unloaded chunks, and a wall-clock expiry would delete the cargo record while the item was still recoverable. - Creative placement no longer duplicates the hull (vanilla keeps the placed item in creative); pickup dissolves a ground copy of a hull whose id you already carry. - The always-sells purchase sheds a carried old hull at the quay via BoatService.shedCarriedHull, shared with takeBoat - it removes every copy (dedupe) and drops exactly one, under the same swap-quiet. - Encounter boats are scenery: boarding never captures, breaking splinters them - no drop, no farmable hull, no litter record per pirate attack. New: the boat logbook (boats.logbook, default ON) - one console INFO line per lifecycle transition with id, owner and coordinates - and /twadmin boat [restore] to inspect records straight from the database and regenerate a lost active boat as a stamped item, refusing while a loaded avatar exists. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 28 +++ docs/PROGRESS.md | 49 ++++ .../world/bentobox/tradewinds/Settings.java | 9 + .../world/bentobox/tradewinds/TradeWinds.java | 10 + .../tradewinds/commands/AdminBoatCommand.java | 167 +++++++++++++ .../tradewinds/economy/MarketService.java | 10 + .../encounters/EncounterService.java | 12 + .../tradewinds/travel/BoatListener.java | 103 ++++++-- .../tradewinds/travel/BoatService.java | 59 +++++ .../tradewinds/travel/HoldService.java | 2 +- src/main/resources/addon.yml | 6 + src/main/resources/config.yml | 5 + src/main/resources/locales/en-US.yml | 13 + .../travel/BoatArchaeologyTest.java | 234 ++++++++++++++++++ 14 files changed, 683 insertions(+), 24 deletions(-) create mode 100644 src/main/java/world/bentobox/tradewinds/commands/AdminBoatCommand.java create mode 100644 src/test/java/world/bentobox/tradewinds/travel/BoatArchaeologyTest.java diff --git a/TESTING.md b/TESTING.md index 9a48070..662a2e2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -258,6 +258,11 @@ behind it. ## Death and recovery - [ ] Die: the boat stays floating where it was and is still yours. +- [ ] **Die CARRYING the boat item** (regression, 2026-08-06): the chart's + marker moves to the death site, not wherever the boat was last placed. + The console logs "went down with " with coordinates. The dropped + hull never despawns on vanilla's clock; walk back days later and the + kit is still there (unloaded chunks freeze item timers). - [ ] Respawn with the ship far away: you are lent a Bamboo Raft. Board it → confirmation → your real boat becomes OLD BOAT. - [ ] Row back and board it: yours again, and the marker clears. @@ -265,6 +270,29 @@ behind it. - [ ] Log out in open water: another player can take the boat. Log out inside protected space: safe. +## Duplicates and the logbook (2026-08-06) + +- [ ] **Creative placement** (regression): in creative, place your stamped + boat — the item leaves your pack (vanilla would keep it). No route to + two items with one boat-id. +- [ ] Walk over a duplicate of a hull you already carry (spawn one with an + old dupe if any survive): it dissolves — cancelled pickup, item gone, + "duplicate hull dissolved" in the console. +- [ ] **Outright purchase while carrying your old boat**: buy any hull with + the old one in your pack — the old hull drops at your feet UNOWNED + (never rides along in the pack), and no swap-back dialog fires as you + stand on it. +- [ ] Break the pirates' boat after winning an encounter: it splinters — + no item, no plate, and `/twadmin boat` shows no new records for anyone. + Boarding it never offers capture. +- [ ] The console carries a `Boat ...` INFO line for: place, break, pickup, + claim, demote, refit, purchase, death. `boats.logbook: false` silences + them all. +- [ ] `/twadmin boat `: both records with cargo, fuel, last-seen and + avatar state ("afloat right there" / "in X's pack" / "not in any loaded + chunk"). `restore` refuses while the hull is loaded or carried; with the + boat genuinely lost it hands a stamped item, hold contents intact. + --- # Tier 3.5 — Seafarer ranks & islet claiming (~15 min, second account for two checks) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 187a665..8cb1367 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,6 +3,55 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. +## The lost-boat archaeology - four bugs and a logbook (2026-08-06) + +Playtest: Ben died (phantom, 28s after login, 4,400 blocks from home) +carrying his spruce boat; two real days later the chart still pointed the +OLD BOAT marker at his own dock, where nothing was. Region-file forensics +(the server logs said nothing but the vanilla death line) found the whole +death drop intact in an unloaded chunk - item despawn clocks only tick +while chunks are loaded, so the kit was frozen at Age 218/6000 - including +TWO spruce boat items stamped with the same boat-id. Four causes, four +fixes, all guarded by `BoatArchaeologyTest`: + +- **Death drops now move the record to the wreck site.** Manual drops + always did (`onDrop`); death drops bypass `PlayerDropItemEvent`, so the + chart lied forever. New `onDeath` handler re-remembers every stamped hull + in the drops at the death location. Deliberately NO TTL: a wall-clock + expiry would delete the cargo record while the item sat recoverable in an + unloaded chunk. (The chart marker vanishing when Ben got close was + `MARKER_MIN_DISTANCE` working as designed - it points at the record's + position and suppresses within 32 blocks; the position was simply stale.) +- **Creative placement duplicated the hull item** - vanilla does not + consume the placed item in creative, so place-then-break minted a second + stamped copy (the 2026-08-02 two-hulls exploit, reborn). `onPlace` now + strips carried copies of the id after a creative place, and `onPickup` + dissolves a ground item whose id you already carry. +- **The always-sells purchase left a carried old hull in the pack.** + `takeBoat` sheds it; the 2026-08-05 outright-replacement branch did not. + Both now use `BoatService.shedCarriedHull` (removes EVERY copy - dedupe - + drops one stamped hull at the feet, re-remembers position), with the + purchase path using the same swap-quiet as capture so the shed hull is + not offered straight back. +- **Encounter boats minted database records.** The pirates' oak boat, + boarded or broken after the fight, self-registered as an unowned hull + (`recordFor(entity)` registers anything) - one litter record per pirate + attack, forever. Encounter-tagged boats are now scenery: boarding never + captures, breaking splinters them to nothing (a free hull per encounter + win would be farmable). The one orphan record on the test server was + moved to `database/.trash`. + +**The boat logbook** (`boats.logbook`, default ON): one console INFO line +per lifecycle transition - placed, broken up, picked up, claimed, demoted, +refit, shed, handed over, burned, went down with its sailor, restored - +with material, id, owner and coordinates. **`/twadmin boat +[restore]`** reads the records straight from the database (cargo, fuel, +last-seen, whether an avatar is loaded there or in someone's pack) and +regenerates a lost active boat as a stamped item in the player's pack, +refusing while a loaded avatar exists (restoring next to a live hull mints +a duplicate). Together they make the next "where did my boat go?" +answerable without region-file forensics. + ## The puffer shoal - SAFE waters get their spice (2026-08-06) The SAFE band's 2% encounter chance was decorative: every encounter needed diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 1e828d3..0fdf850 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -648,6 +648,13 @@ private static Map defaultFuelValues() { @ConfigEntry(path = "boats.dropped-boat-ttl-minutes") private int droppedBoatTtlMinutes = 30; + @ConfigComment("Write a console INFO line for every boat lifecycle transition: placed,") + @ConfigComment("broken to item, picked up, claimed, demoted, refit, shed, went down with") + @ConfigComment("its sailor. These are rare events, and they are the ONLY trail an admin") + @ConfigComment("has when a player asks where their boat went - leave this on.") + @ConfigEntry(path = "boats.logbook") + private boolean boatLogbook = true; + private static Map defaultBoatRanks() { Map ranks = new java.util.LinkedHashMap<>(); ranks.put("BAMBOO_RAFT", 2); @@ -3300,6 +3307,8 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setRespawnBoat(String respawnBoat) { this.respawnBoat = respawnBoat; } public int getDroppedBoatTtlMinutes() { return droppedBoatTtlMinutes; } public void setDroppedBoatTtlMinutes(int droppedBoatTtlMinutes) { this.droppedBoatTtlMinutes = droppedBoatTtlMinutes; } + public boolean isBoatLogbook() { return boatLogbook; } + public void setBoatLogbook(boolean boatLogbook) { this.boatLogbook = boatLogbook; } public int getDriftValueScale() { return driftValueScale; } public void setDriftValueScale(int driftValueScale) { this.driftValueScale = driftValueScale; } public double getDriftMin() { return driftMin; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 60b5834..1049873 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -27,6 +27,7 @@ import world.bentobox.bentobox.lists.Flags; import world.bentobox.tradewinds.commands.AdminCustomsCommand; import world.bentobox.tradewinds.commands.AdminPriceAuditCommand; +import world.bentobox.tradewinds.commands.AdminBoatCommand; import world.bentobox.tradewinds.commands.AdminRankCommand; import world.bentobox.tradewinds.commands.AdminIslandsCommand; import world.bentobox.tradewinds.commands.AdminReflagCommand; @@ -231,6 +232,7 @@ public void setup() { new AdminWarpFailCommand(this); new AdminPriceAuditCommand(this); new AdminRankCommand(this); + new AdminBoatCommand(this); } }; } @@ -618,6 +620,14 @@ public BoatService getBoatService() { return boatService; } + /** + * @return the boat lifecycle listener (purchases use its swap-quiet so a + * hull shed at the quay is not offered straight back) + */ + public world.bentobox.tradewinds.travel.BoatListener getBoatListener() { + return boatListener; + } + /** * @return the hold GUI */ diff --git a/src/main/java/world/bentobox/tradewinds/commands/AdminBoatCommand.java b/src/main/java/world/bentobox/tradewinds/commands/AdminBoatCommand.java new file mode 100644 index 0000000..7ed409d --- /dev/null +++ b/src/main/java/world/bentobox/tradewinds/commands/AdminBoatCommand.java @@ -0,0 +1,167 @@ +package world.bentobox.tradewinds.commands; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; +import world.bentobox.tradewinds.TradeWinds; +import world.bentobox.tradewinds.dataobjects.BoatHold; +import world.bentobox.tradewinds.travel.HoldService; + +/** + * {@code /twadmin boat [restore]} - the admin's answer to "where did + * my boat go?". + *

+ * Without {@code restore} it reads the player's boat records straight from + * the database: material, cargo aboard, fuel, the last position the chart + * remembers, and whether an avatar (entity or dropped item) is actually + * loaded there right now. With {@code restore} it regenerates the ACTIVE + * boat as a stamped item in the player's pack - the cargo record was never + * in danger, only the physical avatar - refusing while a loaded avatar + * exists, because restoring next to a live hull mints a duplicate. Born of + * the 2026-08-06 archaeology, when finding one lost hull took region-file + * forensics. + * + * @author tastybento + */ +public class AdminBoatCommand extends CompositeCommand { + + public AdminBoatCommand(CompositeCommand parent) { + super(parent, "boat"); + } + + @Override + public void setup() { + setPermission("admin.boat"); + setOnlyPlayer(false); + setParametersHelp("tradewinds.commands.admin.boat.parameters"); + setDescription("tradewinds.commands.admin.boat.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (args.isEmpty() || args.size() > 2) { + showHelp(this, user); + return false; + } + UUID target = getPlayers().getUUID(args.get(0)); + if (target == null) { + user.sendMessage("general.errors.unknown-player", TextVariables.NAME, args.get(0)); + return false; + } + TradeWinds addon = getAddon(); + if (args.size() == 2) { + if (!args.get(1).equalsIgnoreCase("restore")) { + showHelp(this, user); + return false; + } + return restore(user, addon, target, args.get(0)); + } + Optional active = addon.getHoldManager().activeBoat(target); + Optional old = addon.getHoldManager().oldBoat(target); + user.sendMessage("tradewinds.commands.admin.boat.header", TextVariables.NAME, + name(target, args.get(0))); + if (active.isEmpty() && old.isEmpty()) { + user.sendMessage("tradewinds.commands.admin.boat.none", new String[0]); + return true; + } + active.ifPresent(hold -> report(user, addon, "tradewinds.commands.admin.boat.active", hold)); + old.ifPresent(hold -> report(user, addon, "tradewinds.commands.admin.boat.old", hold)); + return true; + } + + private void report(User user, TradeWinds addon, String key, BoatHold hold) { + Material material = Material.matchMaterial(hold.getMaterial()); + String avatar = addon.getBoatService().findPlaced(hold) + .map(entity -> user.getTranslation("tradewinds.commands.admin.boat.avatar-loaded", + "[type]", entity.getType().name(), + "[x]", String.valueOf(entity.getLocation().getBlockX()), + "[y]", String.valueOf(entity.getLocation().getBlockY()), + "[z]", String.valueOf(entity.getLocation().getBlockZ()))) + .orElseGet(() -> carrier(user, addon, hold)); + user.sendMessage(key, + "[material]", hold.getMaterial(), + "[id]", hold.getUniqueId(), + "[used]", String.valueOf(HoldService.slotsUsedIn(hold)), + "[slots]", String.valueOf(addon.getBoatRanks().slots(material)), + "[fuel]", String.format("%.0f", addon.getFuelService().unitsOf(hold)), + "[world]", hold.getWorld(), + "[x]", String.valueOf(hold.getX()), + "[y]", String.valueOf(hold.getY()), + "[z]", String.valueOf(hold.getZ()), + "[avatar]", avatar); + } + + /** + * Where the avatar is when no placed one is loaded: in someone's pack, or + * out of sight (an unloaded chunk - the chart position is the lead). + */ + private String carrier(User user, TradeWinds addon, BoatHold hold) { + for (Player online : Bukkit.getOnlinePlayers()) { + if (addon.getBoatService().isCarrying(online, hold) + || (online.getVehicle() != null && hold.getUniqueId() + .equals(world.bentobox.tradewinds.travel.BoatService.boatId(online.getVehicle())))) { + return user.getTranslation("tradewinds.commands.admin.boat.avatar-with", TextVariables.NAME, + online.getName()); + } + } + return user.getTranslation("tradewinds.commands.admin.boat.avatar-unloaded", new String[0]); + } + + private boolean restore(User user, TradeWinds addon, UUID target, String name) { + Optional active = addon.getHoldManager().activeBoat(target); + if (active.isEmpty()) { + user.sendMessage("tradewinds.commands.admin.boat.none", new String[0]); + return false; + } + BoatHold hold = active.get(); + Player player = Bukkit.getPlayer(target); + if (player == null) { + user.sendMessage("general.errors.offline-player", new String[0]); + return false; + } + // A loaded avatar means the boat is NOT lost - restoring would mint a + // duplicate hull. An avatar frozen in an unloaded chunk cannot be + // seen from here; the admin decides, and the logbook records it. + Optional placed = addon.getBoatService().findPlaced(hold); + if (placed.isPresent()) { + user.sendMessage("tradewinds.commands.admin.boat.hull-exists", + "[x]", String.valueOf(placed.get().getLocation().getBlockX()), + "[y]", String.valueOf(placed.get().getLocation().getBlockY()), + "[z]", String.valueOf(placed.get().getLocation().getBlockZ())); + return false; + } + if (addon.getBoatService().isCarrying(player, hold)) { + user.sendMessage("tradewinds.commands.admin.boat.hull-carried", new String[0]); + return false; + } + addon.getBoatService().logbook("restored from the database by " + user.getName(), hold, + player.getLocation()); + addon.getBoatService().giveBoatItem(player, hold); + user.sendMessage("tradewinds.commands.admin.boat.restored", TextVariables.NAME, + name(target, name), "[material]", hold.getMaterial()); + return true; + } + + private String name(UUID target, String fallback) { + String known = getPlayers().getName(target); + return known == null || known.isEmpty() ? fallback : known; + } + + @Override + public Optional> tabComplete(User user, String alias, List args) { + // Raw chain includes this command's label: ["boat", ""...] + if (args.size() <= 2) { + return Optional.of(Util.getOnlinePlayerList(user)); + } + return Optional.of(Util.tabLimit(List.of("restore"), args.get(args.size() - 1))); + } +} diff --git a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java index 4d9e656..27247f8 100644 --- a/src/main/java/world/bentobox/tradewinds/economy/MarketService.java +++ b/src/main/java/world/bentobox/tradewinds/economy/MarketService.java @@ -680,6 +680,8 @@ public boolean buyBoat(Player player, IslandSpec spec, BoatRanks.Rank rank) { if (owned.isEmpty()) { // No ship at all: they are buying one outright, hull in hand var fresh = addon.getBoatService().createFor(player, rank.material()); + addon.getBoatService().logbook("bought by " + player.getName() + " at " + spec.name(), fresh, + player.getLocation()); addon.getBoatService().giveBoatItem(player, fresh); user.sendMessage("tradewinds.trade.boat-bought-first", "[material]", pretty(rank.material()), "[slots]", String.valueOf(rank.slots()), "[price]", Money.format(addon, price)); @@ -699,7 +701,15 @@ public boolean buyBoat(Player player, IslandSpec spec, BoatRanks.Rank rank) { String oldName = Material.matchMaterial(old.getMaterial()) == null ? old.getMaterial() : pretty(Material.matchMaterial(old.getMaterial())); var fresh = addon.getBoatService().createFor(player, rank.material()); + addon.getBoatService().logbook("bought by " + player.getName() + " at " + spec.name() + + ", replacing their " + old.getMaterial(), fresh, player.getLocation()); + addon.getBoatService().logbook("demoted to OLD BOAT (owner bought another)", old, null); addon.getBoatService().giveBoatItem(player, fresh); + // "Wherever it lies" may be the buyer's own pack: a carried hull + // is shed at the quay, or the sailor walks out with two boats - + // the 2026-08-02 two-hulls exploit through the shop door + addon.getBoatListener().quietSwaps(player.getUniqueId()); + addon.getBoatService().shedCarriedHull(player, old); // The plate on the abandoned hull flips to UNOWNED, if it is loaded addon.getBoatService().relabel(old); user.sendMessage("tradewinds.trade.boat-replaced", "[material]", pretty(rank.material()), diff --git a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java index 2e992c0..03e9366 100644 --- a/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java +++ b/src/main/java/world/bentobox/tradewinds/encounters/EncounterService.java @@ -224,6 +224,18 @@ private void tag(Entity entity, EncounterType type) { entity.getPersistentDataContainer().set(ENCOUNTER_KEY, PersistentDataType.STRING, type.name()); } + /** + * Whether this entity is part of an encounter - a mob, or the boat a crew + * arrived in. Encounter BOATS are scenery, never boats a sailor can own: + * left registerable, every pirate attack minted an unowned hull with a + * database record of its own (found moored off a player's island on + * 2026-08-06), and a farmable free boat per encounter besides. + */ + public static boolean isEncounterCraft(Entity entity) { + return entity != null + && entity.getPersistentDataContainer().has(ENCOUNTER_KEY, PersistentDataType.STRING); + } + /** * How close a player must be before THIS mob's crew abandons ship, from * the config by encounter type. Ranged crews fight from the deck and only diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java index ba1697b..30940fb 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatListener.java @@ -146,6 +146,19 @@ public void onPlace(org.bukkit.event.entity.EntityPlaceEvent event) { record.setExpiresAt(0); // Afloat again: only ITEM hulls run a clock addon.getBoatService().stamp(boat, record); addon.getHoldManager().rememberPosition(record, boat.getLocation()); + addon.getBoatService().logbook("placed by " + event.getPlayer().getName(), record, boat.getLocation()); + // Creative placement does not consume the item, so the pack still + // holds a stamped copy of the hull now floating in the water - the + // duplication that put TWO spruce boats in one death drop + // (archaeology, 2026-08-06). The entity is the boat now; every + // carried copy of its id must go. + if (event.getPlayer().getGameMode() == org.bukkit.GameMode.CREATIVE) { + for (ItemStack stack : event.getPlayer().getInventory().getContents()) { + if (stack != null && record.getUniqueId().equals(BoatService.boatId(stack))) { + stack.setAmount(0); + } + } + } } // -------------------------------------------------------------- boarding @@ -160,6 +173,11 @@ public void onEnter(VehicleEnterEvent event) { if (!boat.getPassengers().isEmpty()) { return; } + // An encounter crew's boat is scenery: sit in it if you like, but it + // can never be captured or registered as a hull of your own + if (world.bentobox.tradewinds.encounters.EncounterService.isEncounterCraft(boat)) { + return; + } BoatHold hold = addon.getBoatService().recordFor(boat); UUID playerId = player.getUniqueId(); if (playerId.toString().equals(hold.getOwner())) { @@ -241,6 +259,14 @@ public void onDestroy(VehicleDestroyEvent event) { if (!(event.getVehicle() instanceof Boat boat) || !inOurWorlds(boat.getWorld())) { return; } + // An encounter crew's boat splinters to nothing: no drop, no record. + // A hull per pirate attack would be farmable, and registering them + // littered the database with unowned phantoms (2026-08-06). + if (world.bentobox.tradewinds.encounters.EncounterService.isEncounterCraft(boat)) { + event.setCancelled(true); + boat.remove(); + return; + } BoatHold hold = addon.getBoatService().recordFor(boat); boolean byOwner = event.getAttacker() instanceof Player breaker && breaker.getUniqueId().toString().equals(hold.getOwner()); @@ -254,6 +280,7 @@ public void onDestroy(VehicleDestroyEvent event) { Location where = boat.getLocation(); if (boat.isInLava()) { // The one true destroyer: the hull and everything in it burn + addon.getBoatService().logbook("burned in lava - hull, cargo and record are gone", hold, where); forgetBoat(hold); return; } @@ -266,9 +293,37 @@ public void onDestroy(VehicleDestroyEvent event) { // Only an ITEM ever expires - boat entities stay until someone takes them hold.setExpiresAt(System.currentTimeMillis() + addon.getSettings().getDroppedBoatTtlMinutes() * 60_000L); addon.getHoldManager().rememberPosition(hold, where); + addon.getBoatService().logbook("broken up into an item" + + (event.getAttacker() == null ? "" : " by " + event.getAttacker().getName()), hold, where); where.getWorld().dropItemNaturally(where, item); } + /** + * A sailor going down with a boat item in their pack: the hull scatters + * with the rest of the kit, so the record's position must follow it to + * the wreck site - or the chart points forever at wherever the boat was + * last PLACED. That stale bearing sent its owner searching an empty dock + * while the real hull lay 4,400 blocks away (archaeology, 2026-08-06). + * No TTL is started: death drops freeze in unloaded chunks, and a + * wall-clock expiry would delete the cargo record while the item was + * still recoverable. Vanilla's despawn clock is already held off by + * {@link #onDespawn}. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onDeath(org.bukkit.event.entity.PlayerDeathEvent event) { + Player player = event.getEntity(); + if (!inOurWorlds(player.getWorld())) { + return; + } + for (ItemStack stack : event.getDrops()) { + addon.getBoatService().recordFor(stack).ifPresent(hold -> { + addon.getHoldManager().rememberPosition(hold, player.getLocation()); + addon.getBoatService().logbook("went down with " + player.getName(), hold, + player.getLocation()); + }); + } + } + /** * A boat that burned: the record goes, and anyone pointing at it forgets. */ @@ -323,10 +378,22 @@ public void onPickup(EntityPickupItemEvent event) { event.getItem().setItemStack(stack); return fresh; }); + // Already carrying this very boat? Then the ground copy is a ghost - + // two items, one hold, each opening the same cargo (the 2026-08-02 + // exploit, reborn as creative-placement duplication). The sea keeps + // the spare. + if (addon.getBoatService().isCarrying(player, hold)) { + event.setCancelled(true); + event.getItem().remove(); + addon.getBoatService().logbook("duplicate hull dissolved (already in " + player.getName() + + "'s pack)", hold, event.getItem().getLocation()); + return; + } if (playerId.toString().equals(hold.getOwner())) { 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 (System.currentTimeMillis() < swapQuietUntil.getOrDefault(playerId, 0L)) { @@ -376,11 +443,20 @@ public void onPickup(EntityPickupItemEvent event) { * @param player the player * @param hold the hull they are taking */ + /** + * 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 + * listener would otherwise offer it straight back. + */ + public void quietSwaps(UUID playerId) { + swapQuietUntil.put(playerId, System.currentTimeMillis() + SWAP_QUIET_MS); + } + void takeBoat(Player player, BoatHold hold) { - swapQuietUntil.put(player.getUniqueId(), System.currentTimeMillis() + SWAP_QUIET_MS); + quietSwaps(player.getUniqueId()); Optional previous = addon.getHoldService().active(player.getUniqueId()) .filter(old -> !old.getUniqueId().equals(hold.getUniqueId())); - boolean carried = previous.isPresent() && carriedItemFor(player, previous.get()) != null; + boolean carried = previous.isPresent() && addon.getBoatService().isCarrying(player, previous.get()); boolean alongside = carried || ownBoatWithinReach(player); addon.getBoatService().claim(player, hold); if (previous.isEmpty() || !alongside) { @@ -389,15 +465,8 @@ void takeBoat(Player player, BoatHold hold) { // One hull, all the cargo: pour the old boat into the new one BoatHold old = previous.get(); salvage(player, old); - ItemStack spare = carriedItemFor(player, old); - if (spare != null) { - // The hull cannot ride in your pack alongside your ship - leave it - spare.setAmount(spare.getAmount() - 1); - ItemStack hull = addon.getBoatService() - .stamp(new ItemStack(Material.matchMaterial(old.getMaterial())), old); - player.getWorld().dropItemNaturally(player.getLocation(), hull); - addon.getHoldManager().rememberPosition(old, player.getLocation()); - } + // The hull cannot ride in your pack alongside your ship - leave it + addon.getBoatService().shedCarriedHull(player, old); // Shedding a hull on purpose, emptied, at your own feet is not // "losing" a boat - do not chart it as one to go back for if (old.isEmpty()) { @@ -407,18 +476,6 @@ void takeBoat(Player player, BoatHold hold) { : "tradewinds.boat.merged-partial", "[material]", pretty(Material.matchMaterial(old.getMaterial()))); } - /** - * The item in a player's pack that is the avatar of this boat, or null. - */ - private ItemStack carriedItemFor(Player player, BoatHold hold) { - for (ItemStack stack : player.getInventory().getContents()) { - if (stack != null && hold.getUniqueId().equals(BoatService.boatId(stack))) { - return stack; - } - } - return null; - } - /** * The three ways to answer a hull you have found (ruled 2026-08-02): take * it (abandoning your own where it lies), move its cargo into your own diff --git a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java index c59795b..5dafa30 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/BoatService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/BoatService.java @@ -129,6 +129,59 @@ public boolean isCarrying(Player player, BoatHold hold) { return false; } + /** + * Shed a boat the player is carrying: EVERY item bearing its id leaves + * the pack (a hull can be duplicated - creative placement does not + * consume the item - and the dupes must die together or each one opens + * the same hold), and a single stamped hull is dropped at their feet, + * cargo record intact. This is the only way a hull item may leave a + * pack for the ground on the plugin's initiative: swaps and outright + * purchases both shed, so no sailor ever pockets two boats. + * + * @param player the player + * @param hold the boat to shed + * @return true if anything was actually carried and shed + */ + public boolean shedCarriedHull(Player player, BoatHold hold) { + boolean carried = false; + for (ItemStack stack : player.getInventory().getContents()) { + if (stack != null && hold.getUniqueId().equals(boatId(stack))) { + stack.setAmount(0); + carried = true; + } + } + if (!carried) { + return false; + } + Material material = Material.matchMaterial(hold.getMaterial()); + ItemStack hull = stamp(new ItemStack(material == null ? Material.OAK_BOAT : material), hold); + player.getWorld().dropItemNaturally(player.getLocation(), hull); + addon.getHoldManager().rememberPosition(hold, player.getLocation()); + logbook("shed at " + player.getName() + "'s feet", hold, player.getLocation()); + return true; + } + + // --------------------------------------------------------------- logbook + + /** + * One console line per boat lifecycle transition - the trail that lets an + * admin answer "where did my boat go?" without region-file archaeology + * (which is what it took on 2026-08-06, when a hull went down with its + * sailor 4,400 blocks from where the chart last saw it). + */ + public void logbook(String event, BoatHold hold, Location where) { + if (!addon.getSettings().isBoatLogbook()) { + return; + } + String owner = hold.isUnowned() ? "UNOWNED" + : String.valueOf(Bukkit.getOfflinePlayer(UUID.fromString(hold.getOwner())).getName()); + addon.log("Boat " + hold.getMaterial() + " " + hold.getUniqueId() + " (owner " + owner + "): " + + event + + (where == null || where.getWorld() == null ? "" + : " at " + where.getWorld().getName() + " " + where.getBlockX() + "," + + where.getBlockY() + "," + where.getBlockZ())); + } + /** * Re-write the lore of a carried avatar of this record, so the manifest * stays true after the hold GUI closes. @@ -223,6 +276,10 @@ public void relabel(BoatHold hold) { public void claim(Player player, BoatHold hold) { Optional abandoned = addon.getHoldManager().activeBoat(player.getUniqueId()) .filter(old -> !old.getUniqueId().equals(hold.getUniqueId())); + if (!player.getUniqueId().toString().equals(hold.getOwner())) { + logbook("claimed by " + player.getName(), hold, player.getLocation()); + abandoned.ifPresent(old -> logbook("demoted to OLD BOAT (owner claimed another)", old, null)); + } addon.getHoldManager().setActiveBoat(player.getUniqueId(), hold); // Relabel AFTER the switch: setActiveBoat is what strips the old // boat's owner, and relabelling before it kept the owner's name on @@ -243,6 +300,7 @@ public void claim(Player player, BoatHold hold) { * @param material the hull they bought */ public void refit(Player player, BoatHold hold, Material material) { + logbook("refit from " + hold.getMaterial() + " to " + material.name(), hold, player.getLocation()); hold.setMaterial(material.name()); addon.getHoldManager().save(hold); // Riding it: swap the vessel under them without a swim @@ -309,6 +367,7 @@ public void giveBoatItem(Player player, BoatHold hold) { } ItemStack item = stamp(new ItemStack(material), hold); addon.getHoldManager().rememberPosition(hold, player.getLocation()); + logbook("handed to " + player.getName() + " as an item", hold, player.getLocation()); player.getInventory().addItem(item).values() .forEach(left -> player.getWorld().dropItem(player.getLocation(), left)); } diff --git a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java index d448f9f..29cdfd0 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/HoldService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/HoldService.java @@ -149,7 +149,7 @@ public int slotsUsed(UUID playerId) { return active(playerId).map(HoldService::slotsUsedIn).orElse(0); } - static int slotsUsedIn(BoatHold hold) { + public static int slotsUsedIn(BoatHold hold) { CargoStore.compact(hold.getCargo()); return hold.getExpanders().size() + hold.getCargo().size(); } diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 0742415..e3edf88 100644 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -112,6 +112,12 @@ permissions: tradewinds.admin.reflag: description: Allow re-applying security-band flags to all islands default: op + tradewinds.admin.boat: + description: > + Inspect a player's boat records - cargo, fuel, last-seen position, + whether a hull is actually loaded there - and restore a lost active + boat from the database as an item in their pack. + default: op tradewinds.admin.*: description: All TradeWinds admin permissions default: op diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 083f122..bbb94b5 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -555,6 +555,11 @@ boats: # before the sea claims it. Persisted in the database - restarts do not # reset the clock. dropped-boat-ttl-minutes: 30 + # Write a console INFO line for every boat lifecycle transition: placed, + # broken to item, picked up, claimed, demoted, refit, shed, went down with + # its sailor. These are rare events, and they are the ONLY trail an admin + # has when a player asks where their boat went - leave this on. + logbook: true player: # Career restarts a destitute player may use (/tw restart). -1 unlimited, 0 none. diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 771a637..9e9be62 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -99,6 +99,19 @@ tradewinds: reflag: description: "re-apply security-band flags to all trading islands" done: "Re-applied band flags to [number] trading islands." + boat: + description: "inspect a player's boat records, or restore a lost active boat from the database" + parameters: " [restore]" + header: "[name]'s boats, as the database remembers them:" + none: "No boat records at all." + active: "ACTIVE [material] ([id]) - cargo [used]/[slots], fuel [fuel], last seen [world] [x],[y],[z] - [avatar]" + old: "OLD BOAT [material] ([id]) - cargo [used]/[slots], fuel [fuel], last seen [world] [x],[y],[z] - [avatar]" + avatar-loaded: "afloat right there ([type] at [x],[y],[z])" + avatar-with: "in [name]'s pack or under their feet" + avatar-unloaded: "not in any loaded chunk - the last-seen position is the lead (death drops freeze in unloaded chunks and keep)" + hull-exists: "Not restored: the hull is afloat at [x],[y],[z] - restoring would mint a duplicate." + hull-carried: "Not restored: they are already carrying it." + restored: "[name]'s [material] restored to their pack from the database, cargo record intact." spawn: teleported: "You come ashore at the spawn port. Your boat is your hold and your livelihood - keep it close." resumed: "You take up your voyage where you left it." diff --git a/src/test/java/world/bentobox/tradewinds/travel/BoatArchaeologyTest.java b/src/test/java/world/bentobox/tradewinds/travel/BoatArchaeologyTest.java new file mode 100644 index 0000000..81c7e26 --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/travel/BoatArchaeologyTest.java @@ -0,0 +1,234 @@ +package world.bentobox.tradewinds.travel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 java.util.List; + +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Boat; +import org.bukkit.entity.Item; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.EntityPlaceEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.vehicle.VehicleDestroyEvent; +import org.bukkit.event.vehicle.VehicleEnterEvent; +import org.bukkit.inventory.EquipmentSlot; +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; +import world.bentobox.tradewinds.encounters.EncounterService; + +/** + * The 2026-08-06 archaeology, as regression tests. A sailor died 4,400 + * blocks from home carrying their boat: the chart pointed forever at the + * dock where the hull was last PLACED, the death drop held TWO copies of + * the hull (creative placement does not consume the item), and the pirate + * boat from an old encounter had minted itself a database record. Each dig + * finding gets a guard here. + * + * @author tastybento + */ +class BoatArchaeologyTest extends CommonTestSetup { + + private static final String WORLD_NAME = "tradewinds_world"; + + private TradeWinds addon; + private TestHolds holds; + private BoatService service; + private BoatListener listener; + private BoatHold hold; + + @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); + listener = new BoatListener(addon); + + hold = holds.giveBoat(uuid, Material.OAK_BOAT); + // Last seen moored at the dock, nowhere near where things will happen + hold.setWorld(WORLD_NAME); + hold.setX(9642); + hold.setY(70); + hold.setZ(12955); + } + + /** An item stack mocked as the stamped avatar of the hold. */ + private ItemStack stamped() { + 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(hold.getUniqueId()); + when(meta.getPersistentDataContainer()).thenReturn(pdc); + when(stack.hasItemMeta()).thenReturn(true); + when(stack.getItemMeta()).thenReturn(meta); + when(stack.getType()).thenReturn(Material.OAK_BOAT); + return stack; + } + + private Boat boat(boolean encounter) { + Boat boat = mock(Boat.class); + PersistentDataContainer pdc = mock(PersistentDataContainer.class); + when(pdc.get(BoatService.BOAT_ID_KEY, PersistentDataType.STRING)).thenReturn(hold.getUniqueId()); + when(pdc.has(EncounterService.ENCOUNTER_KEY, PersistentDataType.STRING)).thenReturn(encounter); + when(boat.getPersistentDataContainer()).thenReturn(pdc); + when(boat.getWorld()).thenReturn(world); + when(boat.getLocation()).thenReturn(location); + when(boat.getPassengers()).thenReturn(List.of()); + return boat; + } + + @Test + void testDeathDropMovesTheChartToTheWreckSite() { + // Died at spawn-mock (0,0,0) carrying the boat; the record said the dock + ItemStack aboard = stamped(); + PlayerDeathEvent event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(mockPlayer); + when(event.getDrops()).thenReturn(List.of(aboard)); + + listener.onDeath(event); + + assertEquals(0, hold.getX(), "The chart must point at the wreck, not the dock"); + assertEquals(0, hold.getZ()); + // And NO clock starts: death drops freeze in unloaded chunks, and a + // wall-clock TTL would delete the cargo record while the item keeps + assertEquals(0, hold.getExpiresAt()); + } + + @Test + void testDeathWithoutABoatAboardChangesNothing() { + PlayerDeathEvent event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(mockPlayer); + ItemStack plain = mock(ItemStack.class); + when(event.getDrops()).thenReturn(List.of(plain)); + + listener.onDeath(event); + + assertEquals(9642, hold.getX(), "A kit without the boat must not move its record"); + } + + @Test + void testPickupDissolvesADuplicateHull() { + // Already carrying the boat; its double lies on the ground + ItemStack carried = stamped(); + ItemStack doubled = stamped(); + when(inv.getContents()).thenReturn(new ItemStack[] { carried }); + Item ground = mock(Item.class); + when(ground.getItemStack()).thenReturn(doubled); + when(ground.getLocation()).thenReturn(location); + EntityPickupItemEvent event = mock(EntityPickupItemEvent.class); + when(event.getEntity()).thenReturn(mockPlayer); + when(event.getItem()).thenReturn(ground); + + listener.onPickup(event); + + verify(event).setCancelled(true); + verify(ground).remove(); + } + + @Test + void testCreativePlacementStripsTheLeftoverCopy() { + // Creative placement does not consume the item: after the entity is + // stamped, the pack still holds a live copy of the same hull + when(mockPlayer.getGameMode()).thenReturn(GameMode.CREATIVE); + ItemStack used = stamped(); + ItemStack leftover = stamped(); + when(inv.getItemInMainHand()).thenReturn(used); + when(inv.getContents()).thenReturn(new ItemStack[] { leftover }); + Boat placed = boat(false); + EntityPlaceEvent event = mock(EntityPlaceEvent.class); + when(event.getEntity()).thenReturn(placed); + when(event.getPlayer()).thenReturn(mockPlayer); + when(event.getHand()).thenReturn(EquipmentSlot.HAND); + + listener.onPlace(event); + + verify(leftover).setAmount(0); + } + + @Test + void testSurvivalPlacementLeavesThePackAlone() { + // Vanilla consumes the placed item in survival; anything else with + // the id would be a real second item, and pickup dedupe handles those + when(mockPlayer.getGameMode()).thenReturn(GameMode.SURVIVAL); + ItemStack used = stamped(); + when(inv.getItemInMainHand()).thenReturn(used); + Boat placed = boat(false); + EntityPlaceEvent event = mock(EntityPlaceEvent.class); + when(event.getEntity()).thenReturn(placed); + when(event.getPlayer()).thenReturn(mockPlayer); + when(event.getHand()).thenReturn(EquipmentSlot.HAND); + + listener.onPlace(event); + + verify(used, never()).setAmount(0); + } + + @Test + void testEncounterHullSplintersWithoutARecord() { + int before = addon.getHoldManager().allBoats().size(); + Boat prop = boat(true); + VehicleDestroyEvent event = mock(VehicleDestroyEvent.class); + when(event.getVehicle()).thenReturn(prop); + + listener.onDestroy(event); + + verify(event).setCancelled(true); + verify(prop).remove(); + assertEquals(before, addon.getHoldManager().allBoats().size(), + "A pirate's boat must never mint a database record"); + } + + @Test + void testEncounterHullCannotBeCaptured() { + int before = addon.getHoldManager().allBoats().size(); + Boat prop = boat(true); + VehicleEnterEvent event = mock(VehicleEnterEvent.class); + when(event.getVehicle()).thenReturn(prop); + when(event.getEntered()).thenReturn(mockPlayer); + + listener.onEnter(event); + + verify(event, never()).setCancelled(true); + assertEquals(before, addon.getHoldManager().allBoats().size()); + } + + @Test + void testShedRemovesEveryCopyAndDropsExactlyOne() { + ItemStack first = stamped(); + ItemStack second = stamped(); + when(inv.getContents()).thenReturn(new ItemStack[] { first, second }); + + service.shedCarriedHull(mockPlayer, hold); + + verify(first).setAmount(0); + verify(second).setAmount(0); + verify(world).dropItemNaturally(any(Location.class), any(ItemStack.class)); + assertEquals(0, hold.getX(), "The shed hull's position is the sailor's feet"); + } +} From fa5d7204f01799da2f52e0c76752f6d663c80aa3 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 6 Aug 2026 23:35:40 -0700 Subject: [PATCH 096/112] The interstice becomes a destination: ship graveyard, restless floor, quiet exit Built across one long playtest evening with Ben, iterating on screenshots: - Ship graveyard: seeded vanilla shipwreck templates (IntersticeMap.Wreck, all 20 variants, rotation/burial/grade from the cell hash) placed by the decorator through Structure.place into the LimitedRegion, centre-anchored so 28-block hulls survive chunk borders. Each wreck perches on its own reef mound raised in the terrain itself (wreckSurfaceAt, crest 6-8 under), riding mostly submerged with its top works just breaking the surface. Density tuned live: one hull per ~130 blocks (grid 64, chance 0.25). - Loot mirrors the vanilla nether by grade - fortress chests on common wrecks, bastion finds on rare ones, bastion treasure plus a piglin- bartering captain's locker on the 5% - but only wreck-loot-chance (0.3) of wrecks are stocked at all; the rest are scenery with explicitly emptied chests (no vanilla buried-treasure maps in an ocean that cannot honour them). Grave-lights: a soul flame on every hull's highest timber, mast-carried on the sunken ones. - The interstice map now seeds off the GALAXY seed, not the world's random one: regens are reproducible, coordinates computable offline, and two servers sharing a seed share an interstice (spec principle 5). - The floor stopped being a snooker table: deeper restless seabed profile (rifts, seamounts), a new short-wavelength dune field in Seabed (SeabedConfig.duneHeight, 32-block lattice; 0 by default so pre-dune worlds' floors do not shift - SeabedTest pins byte-identity), and a seafloor-clutter pass (glow lichen, basalt spikes, magma vents, soul-sand seeps, boulders). - Blaze pickets grew from a 3x3 crow's nest to a 9x9 deck covering the spawner's full spawn radius - blazes used to drop straight into the sea. - The re-engage dialog shows ONCE per stranding; /tw go and /tw warp raise it again on demand, and a quiet action-bar line replaces the repeats (prompt-seconds now paces the reminder; 0 silences it). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- TESTING.md | 36 +- docs/PROGRESS.md | 114 ++++++- .../world/bentobox/tradewinds/Settings.java | 84 ++++- .../world/bentobox/tradewinds/TradeWinds.java | 19 +- .../tradewinds/commands/TWSpawnCommand.java | 8 + .../tradewinds/commands/TWWarpCommand.java | 6 + .../tradewinds/galaxy/IntersticeMap.java | 148 ++++++++- .../bentobox/tradewinds/galaxy/Seabed.java | 13 +- .../tradewinds/galaxy/SeabedConfig.java | 17 +- .../generator/ChunkGeneratorWorld.java | 21 +- .../generator/IntersticeDecorator.java | 310 +++++++++++++++++- .../tradewinds/travel/IntersticeService.java | 37 ++- src/main/resources/config.yml | 60 +++- src/main/resources/locales/en-US.yml | 3 +- .../tradewinds/galaxy/IntersticeMapTest.java | 100 +++++- .../tradewinds/galaxy/SeabedTest.java | 45 +++ .../generator/ChunkGeneratorWorldTest.java | 4 +- tradewinds-interstice-plan.md | 26 +- 18 files changed, 1002 insertions(+), 49 deletions(-) create mode 100644 src/test/java/world/bentobox/tradewinds/galaxy/SeabedTest.java diff --git a/TESTING.md b/TESTING.md index 662a2e2..adfa4f9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -427,8 +427,10 @@ data bug - test on a throwaway world first. nether wart growing on it; harvest and replant works; the rim is wadable, not a cliff. Rarely, a shoal carries a crimson/warped grove with shroomlights. - - [ ] Some braziers are **blaze pickets**: nether-brick crow's nest, - blazes spawning - fight from the boat, rods drop. + - [ ] Some braziers are **blaze pickets**: a 9x9 nether-brick deck on + legs, blazes spawning - and STAYING on the deck (regression + 2026-08-07: the old 3x3 nest dropped every blaze into the sea). + Fight from the boat, rods drop. - [ ] **Glowstone clusters** hang from the ceiling lid, visible far off across the water. - [ ] **Quartz ore** in shoal cores and brazier roots, under the @@ -437,9 +439,37 @@ data bug - test on a throwaway world first. repeatedly): wither skeletons in the waterline room, doorway on the north face, loot chest on the sealed floor above (bastion treasure table - check it fills on first open), open top. + - [ ] **The ship graveyard** (v3, 2026-08-07): wrecks are DENSE (a + hull roughly every 130 blocks - regularly in sight) and ride + LOW - mostly submerged, with masts, sterncastles and keels + just breaking the surface (crest 6-8 under; at the first + tuning they stood proud like beached ships). A blue soul + flame burns on every hull's highest timber (mast-carried on + the fully sunken ones). Varied rotations, upside-down and + sideways hulls, no chunk-edge clipping. Most wrecks are + scenery with EMPTY chests; roughly one in three carries loot + - fortress-style, or on a lucky hull bastion finds (Pigstep, + trims) / a treasure wreck whose second chest holds bartering + goods (pearls, crying obsidian). Never a buried-treasure map. + - [ ] **The seafloor is scenery now** (2026-08-07): looking down + through the water shows glow lichen beds, basalt spikes, + magma vents, soul-sand seeps and boulders; the floor itself + rolls - shallow pale banks, dark basins, rifts, and blunt + seamounts that never break the surface. If it still reads + flat, raise interstice.seafloor-clutter. + - [ ] **Config-drift check** (the 2026-08-07 trap): after ANY jar + update that changes config defaults, verify the live + config.yml actually carries the new values - BentoBox keeps + existing keys, so new defaults never reach an existing + install on their own. + - [ ] **The dialog shows ONCE** (changed 2026-08-07): stranding pops + the re-engage dialog a single time; declining leaves only a + quiet action-bar reminder every prompt-seconds. `/tw go` and + `/tw warp` raise the offer again on demand. Leave and get + stranded again: the dialog pops once again. - [ ] A rigged failure never strands you INSIDE a shoal or tower - always open water. - - [ ] The free re-engage still offers after any amount of lingering, + - [ ] The free re-engage still works after any amount of lingering, and ghast tears float where ghasts die. - [ ] Nether wart and ghast tears occasionally drop as encounter booty in the overworld. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 8cb1367..a98caec 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,7 +3,119 @@ What is done, and pitfalls hit on the way. Newest stage first. Read `TRADEWINDS_SPEC.md` for requirements; this file records reality. -## The lost-boat archaeology - four bugs and a logbook (2026-08-06) +## Interstice playtest: the once-only dialog, invisible wrecks, drowned blazes (2026-08-07) + +Three rulings from the first interstice-resources playtest: + +**The re-engage dialog shows ONCE per stranding** (ruled by Ben: the +repeats were irritating). It used to re-open every prompt-seconds - fine +while the interstice was empty water, unbearable once it had wart, blazes +and wrecks worth staying for. Now: dialog once on arrival; after that a +quiet action-bar line every `prompt-seconds` (0 silences it), and +**`/tw go` or `/tw warp` raises the offer again on demand** - both +previously gave useless refusals in the nether world ("already at sea" / +"wrong world"). Leaving the interstice re-arms the one-time dialog; +`IntersticeService.prompted` tracks it, `tick()` retains only players +still in the world. + +**The wrecks were generating all along - they were just invisible AND far +too rare.** Region-file scans found exactly one wreck per regen in the +flown area (a hull spanning chunk borders - so Structure.place into +LimitedRegion works on the live server), on the deep seabed of a dark sea +where it reads as empty water. Ruled by Ben: the point is a VISIBLE, +DENSE ship graveyard - hulls half in, half out of the water, and they do +not all need loot. Graveyard v2: + +- **Reef mounds in the terrain.** `IntersticeMap.wreckSurfaceAt` raises + the seabed under every wreck (dome, radius 18, crest 2-4 blocks under + the surface - the seeded `sink` now varies the crest, so hulls ride + high and dry, awash, or just under: the undulation). The generator + maxes it into the nether floor exactly like shoal domes; the decorator + perches the keel ON the crest instead of burying it in the deep. +- **Density up 10x**: grid 320→96, chance 0.4→0.5 - a hull roughly every + 140 blocks, always one in sight from a boat. +- **Loot rationed**: `interstice.wreck-loot-chance` 0.3 - only that + fraction of wrecks carry stocked chests (grades as before); the REST + ARE SCENERY, chests explicitly emptied (a template chest left alone + rolls vanilla's shipwreck tables, buried-treasure map included). +- **Grave-lights v2** (`interstice.wreck-flames`): the soul flame now + burns on the hull's highest timber; only fully sunken hulls get the + basalt mast to carry it above the surface. At night the graveyard is a + field of cold blue lights. +- Wreck reefs are excluded from `isOpenWater`, so strandings never drop + a sailor into a hull. +- **Graveyard tuning after Ben found them (2026-08-07)**: crest depth + 2→6 blocks under (hulls now ride mostly submerged, top works just + breaking the surface - at 2 they stood proud like beached ships); + density grid 96→64 at 0.5 (a hull every ~90 blocks; "make it denser to + see how much feels right" - back it off in config if it is too much). + The live server config had kept the OLD sparse values across the jar + update - BentoBox preserves existing config keys, so changed DEFAULTS + never reach an existing install; the coordinates handed over were + computed for the new grid and the world was running the old one. + Updated the live config by hand (server stopped). Remember this trap + for every future default change. +- **Dunes (2026-08-07, after "I don't see the sea floor undulating")**: the + Seabed's finest wavelength was RELIEF_LATTICE 220 - +/-10 blocks spread + over 200-block swells is 1-3 blocks across one glance, i.e. visually + flat. New short dune field in `Seabed` (SeabedConfig gains `duneHeight`, + DUNE_LATTICE 32): the interstice runs dunes 4, and the 7-arg + SeabedConfig constructor keeps dunes 0 so every pre-dune world's floor + is byte-identical under its generated chunks (SeabedTest pins both). +- **The interstice seafloor got scenery (2026-08-07)**: seabed profile + deepened and roughed up (shelf 6/abyss 34/relief 10/rifts 16@0.82/ + seamounts 12 - was 10/26/relief 6/no seamounts, flat by design from + when the interstice was a dead end); and a seafloor-clutter pass in the + decorator (interstice.seafloor-clutter, 0.03/column) scatters glow + lichen beds, basalt spikes, magma vents, soul-sand seeps and blackstone + boulders on the bottom. None of it is a resource - it is what makes + looking down through the water worth doing. +- **The interstice map now seeds off the GALAXY seed**, not the interstice + world's own (random-per-creation) seed. Found while trying to hand Ben a + wreck coordinate: with a random world seed, every nether regen shuffled + every feature and no coordinate could be predicted - and two servers + sharing a galaxy seed got different interstices, against spec principle + 5. Now `galaxy.seed` decides the interstice too (world seed only as the + seed-0 fallback), regens are reproducible, and features are computable + offline: with the default seed 20260729, wrecks lie at (32,-40), + (29,55), (-42,68), (-122,164 - loot), ... verified by running + `IntersticeMap` in jshell against a Python port of `Hashing`. + +**Blazes drowned on spawn.** Spawners place mobs up to 4 blocks out +horizontally; the picket's 3x3 crow's nest dumped nearly every blaze into +the sea, where it died on contact. The picket is now a 9x9 nether-brick +deck (DECK_HALF = spawner spawn radius) with a wall ring and corner legs - +every spawn lands on dry deck. Both decorator fixes need a +`tradewinds_world_nether` regen. + +## The ship graveyard - wrecks in the interstice (2026-08-06) + +Ben's idea: the interstice should feel like a ship graveyard. Built the same +day. `IntersticeMap.Wreck` (pure, seeded: grid 320 / chance 0.4, variant, +quarter-turn rotation, 0-2 blocks of burial all from the cell hash) and a +`wrecks()` pass in `IntersticeDecorator` that places the 20 vanilla shipwreck +templates through `Bukkit.getStructureManager().loadStructure` into the +`LimitedRegion` - the `RegionAccessor` overload of `Structure.place`, with +the origin computed per rotation so the hull is CENTERED on the wreck point +(that is what keeps a 28-block template inside chunk+buffer; the rotation +pivots on the origin block, so each rotation wants a different corner). + +Loot mirrors the vanilla nether, by wreck grade (ruled by Ben): COMMON 70% → +fortress chest table, RARE 25% → bastion_other (Pigstep, trims, netherite +upgrade template chance), TREASURE 5% → bastion_treasure plus a captain's +locker chest rolling piglin bartering (pearls, crying obsidian, soul speed). +Every template chest is re-pointed, which also removes vanilla's +buried-treasure maps - nothing in this ocean for them to point at. Pearls +in locker quantities slightly relax the "End goods trade-only" ruling - +recorded in the interstice plan. + +Config: `interstice.wreck-grid/-chance` (needsReset), `wreck-templates` +(any loadable structure key works - servers can add custom wrecks), +`wreck-loot` map (must stay complete in config.yml, as ever). Needs a +`tradewinds_world_nether` regen to appear. NOT yet live-verified: the +`Structure.place`-into-`LimitedRegion` path is API-correct but has not run +on the real server - first playtest should confirm wrecks appear at all +before judging their looks (TESTING.md Tier 6). Playtest: Ben died (phantom, 28s after login, 4,400 blocks from home) carrying his spruce boat; two real days later the chart still pointed the diff --git a/src/main/java/world/bentobox/tradewinds/Settings.java b/src/main/java/world/bentobox/tradewinds/Settings.java index 0fdf850..ae3c232 100644 --- a/src/main/java/world/bentobox/tradewinds/Settings.java +++ b/src/main/java/world/bentobox/tradewinds/Settings.java @@ -976,6 +976,71 @@ private static Map defaultBasePrices() { @ConfigEntry(path = "interstice.watchtower-loot-table") private String intersticeWatchtowerLootTable = "minecraft:chests/bastion_treasure"; + @ConfigComment("Shipwrecks: the interstice is a graveyard of ships that misjumped and never") + @ConfigComment("re-engaged. Grid size in blocks and chance (0-1) a cell holds a wreck -") + @ConfigComment("the defaults put a hull roughly every 130 blocks - a graveyard, not a") + @ConfigComment("scattering. Each wreck perches on its own reef with its top works just") + @ConfigComment("breaking the surface. 0 chance disables the graveyard.") + @ConfigEntry(path = "interstice.wreck-grid", needsReset = true) + private int intersticeWreckGrid = 64; + + @ConfigEntry(path = "interstice.wreck-chance", needsReset = true) + private double intersticeWreckChance = 0.25; + + @ConfigComment("Fraction of wrecks (0-1) whose chests are stocked at all. The rest are") + @ConfigComment("scenery - there to see, their chests empty. Dense graveyard, scarce gold.") + @ConfigEntry(path = "interstice.wreck-loot-chance", needsReset = true) + private double intersticeWreckLootChance = 0.3; + + @ConfigComment("The structure templates wrecks are drawn from - vanilla shipwreck variants") + @ConfigComment("by default; any structure key the server can load works. Which template,") + @ConfigComment("rotation and burial depth a wreck gets is seeded, like everything else.") + @ConfigEntry(path = "interstice.wreck-templates") + private List intersticeWreckTemplates = defaultWreckTemplates(); + + @ConfigComment("Chance per seafloor column of a piece of bottom life: glow lichen beds,") + @ConfigComment("basalt spikes, magma vents, soul-sand seeps, blackstone boulders. This is") + @ConfigComment("what keeps the interstice floor from reading as a snooker table when you") + @ConfigComment("look down through the water. 0 leaves it bare.") + @ConfigEntry(path = "interstice.seafloor-clutter") + private double intersticeSeafloorClutter = 0.03; + + @ConfigComment("Mark every wreck with a soul flame: a charred basalt mast rising through the") + @ConfigComment("hull to just above the surface, blue fire on top. Without it the graveyard") + @ConfigComment("is invisible - hulls on the seabed of a dark sea read as empty water.") + @ConfigEntry(path = "interstice.wreck-flames") + private boolean intersticeWreckFlames = true; + + @ConfigComment("Loot tables by wreck grade. COMMON wrecks (most) carry fortress-style chest") + @ConfigComment("loot; RARE ones carry bastion finds (Pigstep, armor trims, the netherite") + @ConfigComment("upgrade template chance); TREASURE wrecks carry the bastion treasure room -") + @ConfigComment("and their second chest, the captain's locker, rolls LOCKER: piglin-bartering") + @ConfigComment("goods (ender pearls, crying obsidian, soul speed). Every chest in a wreck is") + @ConfigComment("re-pointed at these tables, which also removes vanilla's buried-treasure maps") + @ConfigComment("(there is nothing in this ocean for them to point at).") + @ConfigEntry(path = "interstice.wreck-loot") + private Map intersticeWreckLoot = defaultWreckLoot(); + + private static List defaultWreckTemplates() { + List templates = new java.util.ArrayList<>(); + for (String hull : new String[] { "with_mast", "upsidedown_full", "upsidedown_fronthalf", + "upsidedown_backhalf", "sideways_full", "sideways_fronthalf", "sideways_backhalf", + "rightsideup_full", "rightsideup_fronthalf", "rightsideup_backhalf" }) { + templates.add("minecraft:shipwreck/" + hull); + templates.add("minecraft:shipwreck/" + hull + "_degraded"); + } + return templates; + } + + private static Map defaultWreckLoot() { + Map loot = new java.util.LinkedHashMap<>(); + loot.put("COMMON", "minecraft:chests/nether_bridge"); + loot.put("RARE", "minecraft:chests/bastion_other"); + loot.put("TREASURE", "minecraft:chests/bastion_treasure"); + loot.put("LOCKER", "minecraft:gameplay/piglin_bartering"); + return loot; + } + @ConfigComment("Ghasts spawned around a stranded sailor in the interstice.") @ConfigEntry(path = "interstice.ghasts-min") private int intersticeGhastsMin = 1; @@ -983,7 +1048,10 @@ private static Map defaultBasePrices() { @ConfigEntry(path = "interstice.ghasts-max") private int intersticeGhastsMax = 3; - @ConfigComment("Seconds between offers of the free re-engage while adrift in the interstice.") + @ConfigComment("Seconds between quiet action-bar reminders that the free way out exists,") + @ConfigComment("while adrift in the interstice. The re-engage DIALOG shows once per") + @ConfigComment("stranding; /tw go (or /tw warp) raises it again on demand. 0 silences") + @ConfigComment("the reminders entirely.") @ConfigEntry(path = "interstice.prompt-seconds") private int intersticePromptSeconds = 20; @@ -3385,6 +3453,20 @@ public world.bentobox.tradewinds.galaxy.SecurityBand safestFugitiveTrader() { public void setIntersticeWatchtowerChance(double v) { this.intersticeWatchtowerChance = v; } public String getIntersticeWatchtowerLootTable() { return intersticeWatchtowerLootTable; } public void setIntersticeWatchtowerLootTable(String v) { this.intersticeWatchtowerLootTable = v; } + public int getIntersticeWreckGrid() { return intersticeWreckGrid; } + public void setIntersticeWreckGrid(int intersticeWreckGrid) { this.intersticeWreckGrid = intersticeWreckGrid; } + public double getIntersticeWreckChance() { return intersticeWreckChance; } + public void setIntersticeWreckChance(double intersticeWreckChance) { this.intersticeWreckChance = intersticeWreckChance; } + public double getIntersticeWreckLootChance() { return intersticeWreckLootChance; } + public void setIntersticeWreckLootChance(double v) { this.intersticeWreckLootChance = v; } + public List getIntersticeWreckTemplates() { return intersticeWreckTemplates; } + public void setIntersticeWreckTemplates(List v) { this.intersticeWreckTemplates = v; } + public boolean isIntersticeWreckFlames() { return intersticeWreckFlames; } + public void setIntersticeWreckFlames(boolean intersticeWreckFlames) { this.intersticeWreckFlames = intersticeWreckFlames; } + public double getIntersticeSeafloorClutter() { return intersticeSeafloorClutter; } + public void setIntersticeSeafloorClutter(double v) { this.intersticeSeafloorClutter = v; } + public Map getIntersticeWreckLoot() { return intersticeWreckLoot; } + public void setIntersticeWreckLoot(Map v) { this.intersticeWreckLoot = v; } public int getIntersticeGraceSeconds() { return intersticeGraceSeconds; } public void setIntersticeGraceSeconds(int v) { this.intersticeGraceSeconds = v; } public int getIntersticeGhastsMin() { return intersticeGhastsMin; } diff --git a/src/main/java/world/bentobox/tradewinds/TradeWinds.java b/src/main/java/world/bentobox/tradewinds/TradeWinds.java index 1049873..3cea42c 100644 --- a/src/main/java/world/bentobox/tradewinds/TradeWinds.java +++ b/src/main/java/world/bentobox/tradewinds/TradeWinds.java @@ -742,20 +742,27 @@ public GalaxyEngine getGalaxyEngine(long worldSeed) { } /** - * The interstice's feature map (wart shoals, watchtowers) - pure seeded - * geometry, salted off the same seed the interstice sea floor uses so one - * number still decides the whole world. + * The interstice's feature map (wart shoals, watchtowers, the ship + * graveyard) - pure seeded geometry off the GALAXY seed (fixed + * 2026-08-07; it used to salt the interstice world's own seed, which is + * random on every world creation, so each nether regen shuffled every + * feature and two servers sharing a galaxy seed got different + * interstices - against spec principle 5, "the seed is the world"). + * The world-seed fallback only applies when galaxy.seed is 0. * - * @param worldSeed the interstice world's seed + * @param worldSeed the interstice world's seed, used only as the + * galaxy.seed=0 fallback * @return the map */ public world.bentobox.tradewinds.galaxy.IntersticeMap getIntersticeMap(long worldSeed) { if (intersticeMap == null) { Settings s = getSettings(); - intersticeMap = new world.bentobox.tradewinds.galaxy.IntersticeMap(worldSeed ^ 0x1E7E2571CEL, + long seed = s.getGalaxySeed() != 0 ? s.getGalaxySeed() : worldSeed; + intersticeMap = new world.bentobox.tradewinds.galaxy.IntersticeMap(seed ^ 0x1E7E2571CEL, s.getIntersticeShoalGrid(), s.getIntersticeShoalChance(), s.getIntersticeShoalRadius(), s.getIntersticeGrandShoalChance(), s.getIntersticeWatchtowerGrid(), - s.getIntersticeWatchtowerChance()); + s.getIntersticeWatchtowerChance(), s.getIntersticeWreckGrid(), + s.getIntersticeWreckChance(), s.getIntersticeWreckLootChance()); } return intersticeMap; } diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java index e839233..6c3e905 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWSpawnCommand.java @@ -49,6 +49,14 @@ public void setup() { @Override public boolean execute(User user, String label, List args) { TradeWinds addon = getAddon(); + // In the interstice, the door out is the free re-engage: raise the + // offer on demand. The dialog only pops by itself once per stranding + // (ruled 2026-08-07 - the repeats were unbearable once the interstice + // had wart, blazes and wrecks worth staying for). + if (user.getWorld().equals(addon.getNetherWorld())) { + addon.getIntersticeService().openReEngageDialog(user.getPlayer()); + return true; + } if (addon.inWorld(user.getWorld())) { return goAtSea(addon, user); } diff --git a/src/main/java/world/bentobox/tradewinds/commands/TWWarpCommand.java b/src/main/java/world/bentobox/tradewinds/commands/TWWarpCommand.java index 2c65ad0..f42d153 100644 --- a/src/main/java/world/bentobox/tradewinds/commands/TWWarpCommand.java +++ b/src/main/java/world/bentobox/tradewinds/commands/TWWarpCommand.java @@ -33,6 +33,12 @@ public void setup() { @Override public boolean execute(User user, String label, List args) { TradeWinds addon = getAddon(); + // A castaway asking to warp wants the way out: raise the free + // re-engage. Before the boat check - castaways may be swimming. + if (user.getWorld().equals(addon.getNetherWorld())) { + addon.getIntersticeService().openReEngageDialog(user.getPlayer()); + return true; + } if (!(user.getPlayer().getVehicle() instanceof Boat)) { user.sendMessage("tradewinds.warp.not-in-boat"); return false; diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/IntersticeMap.java b/src/main/java/world/bentobox/tradewinds/galaxy/IntersticeMap.java index c0d3a5e..275cbcf 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/IntersticeMap.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/IntersticeMap.java @@ -7,10 +7,10 @@ /** * The interstice's features, as pure seeded geometry: wart shoals (small - * soul-sand banks breaking the dark sea) and wither watchtowers (its only - * structure). Everything is a pure function of (seed, position) - no Bukkit - * imports, headlessly testable - mirroring how the galaxy treats wild islets - * (spec principle 5). + * soul-sand banks breaking the dark sea), wither watchtowers, and the ship + * graveyard (wrecks of hulls that misjumped and never re-engaged). Everything + * is a pure function of (seed, position) - no Bukkit imports, headlessly + * testable - mirroring how the galaxy treats wild islets (spec principle 5). *

* Interstice-plan sources 1 and 5; the decorator draws crops, groves, quartz * and the tower masonry on top of what this class places. @@ -28,6 +28,28 @@ public class IntersticeMap { private static final long SALT_TOWER = 0x70E51L; private static final long SALT_TOWER_X = 0x70E52L; private static final long SALT_TOWER_Z = 0x70E53L; + private static final long SALT_WRECK = 0x3EC0D1L; + private static final long SALT_WRECK_X = 0x3EC0D2L; + private static final long SALT_WRECK_Z = 0x3EC0D3L; + private static final long SALT_WRECK_KIND = 0x3EC0D4L; + private static final long SALT_WRECK_ROT = 0x3EC0D5L; + private static final long SALT_WRECK_GRADE = 0x3EC0D6L; + private static final long SALT_WRECK_SINK = 0x3EC0D7L; + private static final long SALT_WRECK_LOOT = 0x3EC0D8L; + + /** Radius of the reef mound raised under every wreck. */ + private static final int WRECK_MOUND_RADIUS = 18; + /** The shallowest a wreck mound crests: this far under the sea surface. + * Deep enough that a hull perched on it rides mostly UNDER the water - + * a stern, a cabin roof, a mast poking out (tuned 2026-08-07: at 2 the + * hulls stood proud like beached ships; Ben wants them just breaking + * the surface). */ + private static final int WRECK_CREST_DEPTH = 6; + + /** Fraction of wrecks that are COMMON (fortress-grade loot). */ + private static final double GRADE_COMMON = 0.70; + /** COMMON plus this fraction are RARE (bastion finds); the rest TREASURE. */ + private static final double GRADE_RARE = 0.25; /** Shoal radii roll between these fractions of the nominal radius. */ private static final double MIN_SCALE = 0.6; @@ -58,6 +80,35 @@ public long distanceSquared(int blockX, int blockZ) { public record Tower(int centerX, int centerZ) { } + /** A wreck's loot grade - most hulls carried ordinary cargo. */ + public enum WreckGrade { + COMMON, RARE, TREASURE + } + + /** + * A shipwreck perched on its own reef mound, half in and half out of the + * water: a ship that misjumped and never re-engaged. Most are scenery - + * only {@code loot} wrecks carry stocked chests. + * + * @param centerX block x of the hull's centre + * @param centerZ block z of the hull's centre + * @param variant non-negative pick for the template list (mod list size) + * @param rotation quarter-turns, 0-3 + * @param grade what its chests are worth, when they are worth anything + * @param sink how much deeper than the shallowest crest this reef sits, + * 0-2 - the undulation that puts some hulls high and dry and some + * just awash + * @param loot whether this wreck's chests are stocked at all + */ + public record Wreck(int centerX, int centerZ, int variant, int rotation, WreckGrade grade, int sink, + boolean loot) { + public long distanceSquared(int blockX, int blockZ) { + long dx = (long) blockX - centerX; + long dz = (long) blockZ - centerZ; + return dx * dx + dz * dz; + } + } + private final long seed; private final int shoalGrid; private final double shoalChance; @@ -65,9 +116,13 @@ public record Tower(int centerX, int centerZ) { private final double grandShoalChance; private final int towerGrid; private final double towerChance; + private final int wreckGrid; + private final double wreckChance; + private final double wreckLootChance; public IntersticeMap(long seed, int shoalGrid, double shoalChance, int shoalRadius, - double grandShoalChance, int towerGrid, double towerChance) { + double grandShoalChance, int towerGrid, double towerChance, int wreckGrid, + double wreckChance, double wreckLootChance) { this.seed = seed; this.shoalGrid = Math.max(64, shoalGrid); this.shoalChance = shoalChance; @@ -75,6 +130,9 @@ public IntersticeMap(long seed, int shoalGrid, double shoalChance, int shoalRadi this.grandShoalChance = grandShoalChance; this.towerGrid = Math.max(256, towerGrid); this.towerChance = towerChance; + this.wreckGrid = Math.max(64, wreckGrid); + this.wreckChance = wreckChance; + this.wreckLootChance = wreckLootChance; } /** @@ -153,6 +211,10 @@ public boolean isOpenWater(int blockX, int blockZ) { if (shoalSurfaceAt(blockX, blockZ, 0).isPresent()) { return false; } + // Wreck reefs crest just under the surface - not water to strand in + if (!wrecksNear(blockX, blockZ, WRECK_MOUND_RADIUS + 2).isEmpty()) { + return false; + } return towersNear(blockX, blockZ, 12).isEmpty(); } @@ -174,6 +236,82 @@ public Optional towerInCell(int cellX, int cellZ) { return Optional.of(new Tower(x, z)); } + /** + * The wreck hosted by a wreck-grid cell, if any. Grade is weighted - + * most hulls carried ordinary cargo, a few were worth chasing. + */ + public Optional wreckInCell(int cellX, int cellZ) { + if (wreckChance <= 0) { + return Optional.empty(); + } + if (Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_WRECK)) >= wreckChance) { + return Optional.empty(); + } + int jitter = wreckGrid / 4; + double jx = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_WRECK_X)) * 2 - 1; + double jz = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_WRECK_Z)) * 2 - 1; + int x = (int) Math.round((cellX + 0.5) * wreckGrid + jx * jitter); + int z = (int) Math.round((cellZ + 0.5) * wreckGrid + jz * jitter); + int variant = (int) (Math.abs(Hashing.cellHash(seed, cellX, cellZ, SALT_WRECK_KIND)) % 1024); + int rotation = (int) Math.floorMod(Hashing.cellHash(seed, cellX, cellZ, SALT_WRECK_ROT), 4); + double roll = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_WRECK_GRADE)); + WreckGrade grade = roll < GRADE_COMMON ? WreckGrade.COMMON + : roll < GRADE_COMMON + GRADE_RARE ? WreckGrade.RARE : WreckGrade.TREASURE; + int sink = (int) Math.floorMod(Hashing.cellHash(seed, cellX, cellZ, SALT_WRECK_SINK), 3); + boolean loot = Hashing.toUnit(Hashing.cellHash(seed, cellX, cellZ, SALT_WRECK_LOOT)) < wreckLootChance; + return Optional.of(new Wreck(x, z, variant, rotation, grade, sink, loot)); + } + + /** + * The floor-top Y a wreck's reef mound wants at a column, if any wreck's + * mound reaches it: a steep dome cresting {@code WRECK_CREST_DEPTH + + * sink} blocks under the surface, so the hull perched on it rides half + * in and half out of the water - visible from the deck of a passing + * boat, which is the whole point of a graveyard (ruled by Ben, + * 2026-08-07: wrecks on the deep seabed read as empty water). The + * generator takes the max of this and the natural floor. + * + * @param blockX block x + * @param blockZ block z + * @param seaLevel the interstice sea surface Y + * @return the wanted floor-top Y, or empty when no mound reaches here + */ + public OptionalInt wreckSurfaceAt(int blockX, int blockZ, int seaLevel) { + int best = Integer.MIN_VALUE; + for (Wreck wreck : wrecksNear(blockX, blockZ, WRECK_MOUND_RADIUS)) { + double r = Math.sqrt(wreck.distanceSquared(blockX, blockZ)); + if (r > WRECK_MOUND_RADIUS) { + continue; + } + int crest = seaLevel - WRECK_CREST_DEPTH - wreck.sink(); + int y = crest - (int) Math.round(Math.pow(r / WRECK_MOUND_RADIUS, 1.3) * 14); + best = Math.max(best, y); + } + return best == Integer.MIN_VALUE ? OptionalInt.empty() : OptionalInt.of(best); + } + + /** + * Wrecks whose centre lies within range of a position. + */ + public List wrecksNear(int blockX, int blockZ, int range) { + List found = new ArrayList<>(); + int reach = range + wreckGrid / 4; + int minCellX = Math.floorDiv(blockX - reach, wreckGrid); + int maxCellX = Math.floorDiv(blockX + reach, wreckGrid); + int minCellZ = Math.floorDiv(blockZ - reach, wreckGrid); + int maxCellZ = Math.floorDiv(blockZ + reach, wreckGrid); + for (int cx = minCellX; cx <= maxCellX; cx++) { + for (int cz = minCellZ; cz <= maxCellZ; cz++) { + wreckInCell(cx, cz).filter(w -> { + long dx = (long) w.centerX() - blockX; + long dz = (long) w.centerZ() - blockZ; + return dx * dx + dz * dz <= (long) (range + 1) * (range + 1) || range <= 0; + }).ifPresent(found::add); + } + } + return found; + } + /** * Towers within range of a position. */ diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java b/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java index 96ff4e0..1a055ea 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/Seabed.java @@ -32,6 +32,7 @@ public class Seabed { private static final long SALT_RIFT = 0x5EABED03L; private static final long SALT_SEAMOUNT = 0x5EABED04L; private static final long SALT_SEDIMENT = 0x5EABED05L; + private static final long SALT_DUNE = 0x5EABED06L; /** Scale of the basins, in blocks: a long swim from shelf to deep water. */ private static final int BASIN_LATTICE = 2200; @@ -43,6 +44,8 @@ public class Seabed { private static final int SEAMOUNT_LATTICE = 1400; /** Scale of the sediment patches (sand, gravel, clay), in blocks. */ private static final int SEDIMENT_LATTICE = 26; + /** Scale of the short dunes - visible within one glance down from a boat. */ + private static final int DUNE_LATTICE = 32; /** * Minimum water depth over open sea. Seamounts and shoals stop here so no @@ -100,6 +103,14 @@ private double depthAt(int blockX, int blockZ, double shelfBlend) { double relief = (Noise.fbm(seed, SALT_RELIEF, blockX, blockZ, RELIEF_LATTICE, 3, 0.5) - 0.5) * 2 * config.relief(); depth += relief * (1 - blend / 2); + // Short dunes: the 220-block relief swells read as dead flat from a + // boat ("I don't see the sea floor undulating", 2026-08-07); these + // are the waves you can actually watch roll under the hull + if (config.duneHeight() > 0) { + double dune = (Noise.fbm(seed, SALT_DUNE, blockX, blockZ, DUNE_LATTICE, 2, 0.5) - 0.5) * 2 + * config.duneHeight(); + depth += dune * (1 - blend / 2); + } depth += riftCut(blockX, blockZ) * (1 - blend); depth -= seamountRise(blockX, blockZ, basin) * (1 - blend); // Open water everywhere: a shoal that reached the surface would be land @@ -201,7 +212,7 @@ public int maxFloorY() { * @return minimum floor Y */ public int minFloorY() { - return seaLevel - config.maxDepth() - config.relief(); + return seaLevel - config.maxDepth() - config.relief() - config.duneHeight(); } /** diff --git a/src/main/java/world/bentobox/tradewinds/galaxy/SeabedConfig.java b/src/main/java/world/bentobox/tradewinds/galaxy/SeabedConfig.java index b4533b4..46057d2 100644 --- a/src/main/java/world/bentobox/tradewinds/galaxy/SeabedConfig.java +++ b/src/main/java/world/bentobox/tradewinds/galaxy/SeabedConfig.java @@ -20,15 +20,29 @@ * @param seamountHeight how far a seamount rises above the surrounding floor at * its peak, in blocks; 0 disables them. Seamounts never break the * surface - the generator keeps open water above them + * @param duneHeight amplitude in blocks of short dunes riding on everything + * else - the wavelength you can SEE roll while looking down from a + * boat, where the relief field's 220-block swells read as flat; 0 + * disables them (and keeps pre-dune worlds' terrain unchanged) * * @author tastybento */ public record SeabedConfig(int shelfDepth, int abyssDepth, int islandShelfDepth, int relief, int riftDepth, - double riftThreshold, int seamountHeight) { + double riftThreshold, int seamountHeight, int duneHeight) { /** The default sea floor: shelves at 14 down to basins at 46. */ public static final SeabedConfig DEFAULT = new SeabedConfig(14, 46, 18, 9, 26, 0.80, 20); + /** + * The pre-dune shape: everything but dunes, dunes off. Every caller + * predating 2026-08-07 uses this form, and their worlds' floors must not + * shift under already-generated chunks. + */ + public SeabedConfig(int shelfDepth, int abyssDepth, int islandShelfDepth, int relief, int riftDepth, + double riftThreshold, int seamountHeight) { + this(shelfDepth, abyssDepth, islandShelfDepth, relief, riftDepth, riftThreshold, seamountHeight, 0); + } + /** * A flat floor at a fixed depth - the pre-Stage-6 ocean, and what * {@code world.seabed.vary: false} gives you. @@ -51,6 +65,7 @@ public static SeabedConfig flat(int depth) { riftDepth = Math.max(0, riftDepth); riftThreshold = Math.clamp(riftThreshold, 0.0, 1.0); seamountHeight = Math.max(0, seamountHeight); + duneHeight = Math.max(0, duneHeight); } /** diff --git a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java index d1f0f0e..781d5c9 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java +++ b/src/main/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorld.java @@ -47,10 +47,14 @@ private record WorldConfig(int seaHeight, int seaFloor, Material waterBlock) { } /** - * The interstice's sea floor: shallower, tighter, and no seamounts - it is - * meant to feel like a dead end, not a place to explore. + * The interstice's sea floor: shallower than the ocean's but restless - + * pale banks near the surface, basins falling away dark, rifts, blunt + * seamounts looming under the hull. It was flat-by-design back when the + * interstice was a dead end nobody explored; once it grew wart, wrecks + * and blazes worth visiting, "the floor does seem too flat" (playtest + * 2026-08-07) - a destination deserves scenery. */ - private static final SeabedConfig INTERSTICE_SEABED = new SeabedConfig(10, 26, 10, 6, 12, 0.85, 0); + private static final SeabedConfig INTERSTICE_SEABED = new SeabedConfig(6, 34, 10, 10, 16, 0.82, 12, 4); /** Salt so the interstice floor does not mirror the overworld's. */ private static final long INTERSTICE_SALT = 0x1E7E2571CEL; @@ -260,11 +264,18 @@ private int floorTopAt(WorldInfo worldInfo, WorldConfig wc, Seabed floor, Galaxy if (worldInfo.getEnvironment() == Environment.NETHER) { // Wart shoals: the interstice's only land, a low soul-sand dome // the natural floor rises to meet (interstice plan, source 1) - var wanted = addon.getIntersticeMap(worldInfo.getSeed()).shoalSurfaceAt(worldX, worldZ, - wc.seaHeight()); + var map = addon.getIntersticeMap(worldInfo.getSeed()); + var wanted = map.shoalSurfaceAt(worldX, worldZ, wc.seaHeight()); if (wanted.isPresent()) { floorTop = Math.max(floorTop, wanted.getAsInt()); } + // Wreck reefs: the seabed rises under every wreck so the hull + // perches half out of the water instead of vanishing into the + // deep (interstice plan, source 7) + var reef = map.wreckSurfaceAt(worldX, worldZ, wc.seaHeight()); + if (reef.isPresent()) { + floorTop = Math.max(floorTop, reef.getAsInt()); + } } return Math.clamp(floorTop, worldInfo.getMinHeight() + 2, worldInfo.getMaxHeight() - 1); } diff --git a/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java b/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java index 82becbd..a1f3146 100644 --- a/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java +++ b/src/main/java/world/bentobox/tradewinds/generator/IntersticeDecorator.java @@ -9,6 +9,7 @@ import org.bukkit.block.Chest; import org.bukkit.block.CreatureSpawner; import org.bukkit.block.data.Ageable; +import org.bukkit.block.structure.StructureRotation; import org.bukkit.entity.EntityType; import org.bukkit.generator.BlockPopulator; import org.bukkit.generator.LimitedRegion; @@ -30,8 +31,10 @@ *

  • Glowstone clusters hanging from the ceiling lid - light as navigation * and destination at once.
  • *
  • Quartz ore seeded through shoal cores and brazier roots.
  • - *
  • Wither watchtowers: the interstice's only structure - wither skeleton - * spawner below, loot chest above, skulls to a nether star to a beacon.
  • + *
  • Wither watchtowers: wither skeleton spawner below, loot chest above, + * skulls to a nether star to a beacon.
  • + *
  • The ship graveyard: seeded vanilla shipwrecks on the seabed, chests + * re-pointed at nether-tier loot by wreck grade.
  • * * Geometry (where things are) comes from the pure {@link IntersticeMap}; * this class only draws what the map declares. @@ -64,6 +67,92 @@ public void populate(@NonNull WorldInfo worldInfo, @NonNull Random random, int c shoals(map, random, region, chunkX, chunkZ, sea); glowstoneCeiling(random, region, chunkX, chunkZ, worldInfo, sea); watchtower(map, region, chunkX, chunkZ, sea); + wrecks(map, random, region, chunkX, chunkZ, worldInfo, sea); + seafloor(random, region, chunkX, chunkZ, worldInfo, sea); + } + + // ------------------------------------------------------------- seafloor + + /** + * Bottom life: glow lichen beds, basalt spikes, magma vents, soul-sand + * seeps and blackstone boulders scattered on the seabed. None of it is a + * resource - it is what makes looking down through the water worth doing + * ("the floor does seem too flat, especially because nothing is growing + * on it" - playtest 2026-08-07). + */ + private void seafloor(Random random, LimitedRegion region, int chunkX, int chunkZ, WorldInfo worldInfo, + int sea) { + double chance = addon.getSettings().getIntersticeSeafloorClutter(); + if (chance <= 0) { + return; + } + for (int dx = 0; dx < 16; dx++) { + for (int dz = 0; dz < 16; dz++) { + if (random.nextDouble() >= chance) { + continue; + } + int x = (chunkX << 4) + dx; + int z = (chunkZ << 4) + dz; + int floor = seabedAt(region, x, z, sea - 2, worldInfo.getMinHeight()); + if (floor <= worldInfo.getMinHeight()) { + continue; // dry land, a shoal crown, or nothing at all + } + double roll = random.nextDouble(); + if (roll < 0.40) { + lichenBed(random, region, x, floor, z); + } else if (roll < 0.65) { + // A basalt spike, 2-5 tall, always fully submerged + int height = Math.min(2 + random.nextInt(4), sea - 1 - floor); + for (int y = 1; y <= height; y++) { + if (region.isInRegion(x, floor + y, z)) { + region.setType(x, floor + y, z, Material.BASALT); + } + } + } else if (roll < 0.80) { + // A magma vent glowing up through the water + patch(random, region, x, floor, z, Material.MAGMA_BLOCK); + } else if (roll < 0.90) { + // A soul-sand seep + patch(random, region, x, floor, z, Material.SOUL_SAND); + } else { + // A blackstone boulder + if (region.isInRegion(x, floor + 1, z)) { + region.setType(x, floor + 1, z, Material.BLACKSTONE); + } + patch(random, region, x, floor, z, Material.BLACKSTONE); + } + } + } + } + + /** Swap the floor block and a couple of neighbours to a material. */ + private void patch(Random random, LimitedRegion region, int x, int floor, int z, Material material) { + region.setType(x, floor, z, material); + for (int i = 0; i < 2; i++) { + int nx = x + random.nextInt(3) - 1; + int nz = z + random.nextInt(3) - 1; + if (region.isInRegion(nx, floor, nz) && region.getType(nx, floor, nz) != Material.WATER) { + region.setType(nx, floor, nz, material); + } + } + } + + /** A patch of waterlogged glow lichen lying on the seabed. */ + private void lichenBed(Random random, LimitedRegion region, int x, int floor, int z) { + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + if ((dx == 0 && dz == 0 || random.nextBoolean()) + && region.isInRegion(x + dx, floor + 1, z + dz) + && region.getType(x + dx, floor + 1, z + dz) == Material.WATER + && region.getType(x + dx, floor, z + dz) != Material.WATER) { + org.bukkit.block.data.type.GlowLichen lichen = + (org.bukkit.block.data.type.GlowLichen) Material.GLOW_LICHEN.createBlockData(); + lichen.setFace(org.bukkit.block.BlockFace.DOWN, true); + lichen.setWaterlogged(true); + region.setBlockData(x + dx, floor + 1, z + dz, lichen); + } + } + } } // ------------------------------------------------------------- braziers @@ -110,18 +199,38 @@ private void brazier(Random random, LimitedRegion region, int chunkX, int chunkZ } } + /** Blaze deck half-width: spawners place mobs up to 4 blocks out, so the + * deck must cover the full spawn radius or the blazes land in the sea + * and fizzle on the spot (playtest 2026-08-07 - the 3x3 crow's nest + * drowned every blaze it ever spawned). */ + private static final int DECK_HALF = 4; + /** - * A blaze picket: a nether-brick crow's nest on the brazier with a blaze - * spawner burning where the fire would have been. + * A blaze picket: a nether-brick platform on the brazier, wide enough + * that everything the spawner produces lands on dry deck, with a wall + * ring and legs down to the waterline. */ private void picket(LimitedRegion region, int x, int top, int z) { - for (int dx = -1; dx <= 1; dx++) { - for (int dz = -1; dz <= 1; dz++) { + for (int dx = -DECK_HALF; dx <= DECK_HALF; dx++) { + for (int dz = -DECK_HALF; dz <= DECK_HALF; dz++) { if (region.isInRegion(x + dx, top + 1, z + dz)) { region.setType(x + dx, top + 1, z + dz, Material.NETHER_BRICKS); } - if (dx != 0 && dz != 0 && region.isInRegion(x + dx, top + 2, z + dz)) { - region.setType(x + dx, top + 2, z + dz, Material.NETHER_BRICK_WALL); + boolean edge = Math.abs(dx) == DECK_HALF || Math.abs(dz) == DECK_HALF; + boolean corner = Math.abs(dx) == DECK_HALF && Math.abs(dz) == DECK_HALF; + if (edge && region.isInRegion(x + dx, top + 2, z + dz)) { + region.setType(x + dx, top + 2, z + dz, + corner ? Material.NETHER_BRICKS : Material.NETHER_BRICK_WALL); + } + // Corner legs down into the water: a stilt fort, not a UFO + if (corner) { + for (int y = top; y > top - 6 && region.isInRegion(x + dx, y, z + dz); y--) { + Material below = region.getType(x + dx, y, z + dz); + if (below != Material.WATER && below != Material.AIR) { + break; // found footing + } + region.setType(x + dx, y, z + dz, Material.NETHER_BRICK_WALL); + } } } } @@ -320,6 +429,191 @@ private void fill(LimitedRegion region, int cx, int cz, int fromY, int toY, Mate } } + // --------------------------------------------------------------- wrecks + + /** + * The ship graveyard: hulls that misjumped and never re-engaged, lying + * where the interstice keeps them. Each wreck is a vanilla shipwreck + * template (config-swappable), seeded for position, variant, rotation and + * burial by the pure map. Drawn once, from the chunk holding its centre - + * templates are at most 28 blocks long, so a centre-anchored wreck always + * fits inside this chunk plus the populator's buffer. + */ + private void wrecks(IntersticeMap map, Random random, LimitedRegion region, int chunkX, int chunkZ, + WorldInfo worldInfo, int sea) { + java.util.List templates = addon.getSettings().getIntersticeWreckTemplates(); + if (templates.isEmpty()) { + return; + } + int minX = chunkX << 4; + int minZ = chunkZ << 4; + map.wrecksNear(minX + 8, minZ + 8, 16).stream() + .filter(w -> w.centerX() >= minX && w.centerX() < minX + 16 + && w.centerZ() >= minZ && w.centerZ() < minZ + 16) + .forEach(w -> placeWreck(region, random, worldInfo, sea, w, + templates.get(w.variant() % templates.size()))); + } + + private void placeWreck(LimitedRegion region, Random random, WorldInfo worldInfo, int sea, + IntersticeMap.Wreck wreck, String templateKey) { + NamespacedKey key = NamespacedKey.fromString(templateKey); + org.bukkit.structure.Structure template = key == null ? null + : Bukkit.getStructureManager().loadStructure(key); + if (template == null) { + addon.logError("interstice.wreck-templates: '" + templateKey + "' is not a structure"); + return; + } + // Keel on the seabed (minus the seeded burial), found at the centre + int floor = seabedAt(region, wreck.centerX(), wreck.centerZ(), sea, worldInfo.getMinHeight()); + if (floor <= worldInfo.getMinHeight()) { + return; // no seabed here (should not happen; be safe) + } + org.bukkit.util.BlockVector size = template.getSize(); + int sx = size.getBlockX(); + int sz = size.getBlockZ(); + // Keel settled one block into the reef crest: the mound (generator) + // already set the height so the hull rides half out of the water - + // wreck.sink() shaped the CREST, so it is not applied again here + int y = Math.max(worldInfo.getMinHeight() + 1, floor); + StructureRotation rotation = switch (wreck.rotation()) { + case 1 -> StructureRotation.CLOCKWISE_90; + case 2 -> StructureRotation.CLOCKWISE_180; + case 3 -> StructureRotation.COUNTERCLOCKWISE_90; + default -> StructureRotation.NONE; + }; + // place() rotates around the origin block, so the origin that CENTRES + // the hull on the wreck point depends on the rotation. Centring is + // what keeps every block inside this chunk plus the buffer. + org.bukkit.util.BlockVector origin = switch (rotation) { + case CLOCKWISE_90 -> new org.bukkit.util.BlockVector(wreck.centerX() + (sz - 1) / 2, y, + wreck.centerZ() - (sx - 1) / 2); + case CLOCKWISE_180 -> new org.bukkit.util.BlockVector(wreck.centerX() + (sx - 1) / 2, y, + wreck.centerZ() + (sz - 1) / 2); + case COUNTERCLOCKWISE_90 -> new org.bukkit.util.BlockVector(wreck.centerX() - (sz - 1) / 2, y, + wreck.centerZ() + (sx - 1) / 2); + default -> new org.bukkit.util.BlockVector(wreck.centerX() - (sx - 1) / 2, y, + wreck.centerZ() - (sz - 1) / 2); + }; + try { + template.place(region, origin, false, rotation, org.bukkit.block.structure.Mirror.NONE, -1, 1.0f, + random); + } catch (Exception e) { + addon.logError("Wreck " + templateKey + " at " + wreck.centerX() + "," + wreck.centerZ() + + " failed to place: " + e.getMessage()); + return; + } + wreckLoot(region, wreck, size, y); + graveLight(region, wreck, sea); + } + + /** + * A soul flame over every wreck - the grave candle. On a hull riding + * above the water it burns on the highest timber; on a sunken one a + * charred basalt mast carries it up past the surface. At night the + * graveyard is a field of cold blue lights. + */ + private void graveLight(LimitedRegion region, IntersticeMap.Wreck wreck, int sea) { + if (!addon.getSettings().isIntersticeWreckFlames()) { + return; + } + int x = wreck.centerX(); + int z = wreck.centerZ(); + // The hull's highest timber at the centre column + int top = Integer.MIN_VALUE; + for (int y = sea + 24; y >= sea - 24; y--) { + if (!region.isInRegion(x, y, z)) { + return; + } + Material here = region.getType(x, y, z); + if (here != Material.WATER && here != Material.AIR) { + top = y; + break; + } + } + if (top == Integer.MIN_VALUE) { + return; // nothing under the centre at all + } + if (top < sea) { + // Sunken: a mast up out of the water to carry the flame + for (int y = top + 1; y <= sea; y++) { + if (region.isInRegion(x, y, z)) { + region.setType(x, y, z, Material.BASALT); + } + } + top = sea; + } + if (region.isInRegion(x, top + 1, z)) { + region.setType(x, top + 1, z, Material.SOUL_SAND); + } + if (region.isInRegion(x, top + 2, z)) { + region.setType(x, top + 2, z, Material.SOUL_FIRE); + } + } + + /** The first solid column top at or under the sea surface. */ + private int seabedAt(LimitedRegion region, int x, int z, int sea, int minHeight) { + for (int y = sea; y > minHeight; y--) { + if (!region.isInRegion(x, y, z)) { + return minHeight; + } + Material type = region.getType(x, y, z); + if (type != Material.WATER && type != Material.AIR) { + return y; + } + } + return minHeight; + } + + /** + * Settle every chest the template shipped. Most wrecks are SCENERY + * (ruled 2026-08-07: the graveyard is there to see, not to farm) - their + * chests are emptied, because a template chest left alone rolls + * vanilla's shipwreck tables, buried-treasure map included. On a loot + * wreck the first chest carries the wreck's grade; on a TREASURE wreck + * any further chest is the captain's locker (piglin-bartering goods). + */ + private void wreckLoot(LimitedRegion region, IntersticeMap.Wreck wreck, + org.bukkit.util.BlockVector size, int keelY) { + // The rotated hull lies somewhere inside this box around the centre + int reach = Math.max(size.getBlockX(), size.getBlockZ()) / 2 + 1; + int found = 0; + for (int y = keelY; y <= keelY + size.getBlockY(); y++) { + for (int x = wreck.centerX() - reach; x <= wreck.centerX() + reach; x++) { + for (int z = wreck.centerZ() - reach; z <= wreck.centerZ() + reach; z++) { + if (!region.isInRegion(x, y, z)) { + continue; + } + Material type = region.getType(x, y, z); + if (type != Material.CHEST && type != Material.TRAPPED_CHEST) { + continue; + } + LootTable loot = null; + if (wreck.loot()) { + String gradeKey = found > 0 && wreck.grade() == IntersticeMap.WreckGrade.TREASURE + ? "LOCKER" + : wreck.grade().name(); + String tableName = addon.getSettings().getIntersticeWreckLoot().get(gradeKey); + NamespacedKey tableKey = tableName == null ? null + : NamespacedKey.fromString(tableName); + loot = tableKey == null ? null : Bukkit.getLootTable(tableKey); + if (loot == null) { + addon.logError("interstice.wreck-loot." + gradeKey + " '" + tableName + + "' is not a loot table"); + } + } + found++; + BlockState state = region.getBlockState(x, y, z); + if (state instanceof Chest chestState) { + // null clears the template's vanilla table: an empty + // sea chest, not a broken treasure map + chestState.setLootTable(loot); + chestState.update(); + } + } + } + } + } + // ---------------------------------------------------------------- tiles private void spawner(LimitedRegion region, int x, int y, int z, EntityType type) { diff --git a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java index 7e1aa96..6dac0e0 100644 --- a/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java +++ b/src/main/java/world/bentobox/tradewinds/travel/IntersticeService.java @@ -39,8 +39,12 @@ * Nether sea partway along the route with Ghasts inbound. The fuel was spent * at engagement, so re-engaging to the original destination is always * free - the failure is a detour, never a loss, and stranding is - * impossible (spec §3.3). The way out is offered automatically while the - * player floats there. + * impossible (spec §3.3). The way out is offered ONCE on arrival, and on + * demand ever after: {@code /tw go} (or {@code /tw warp}) raises the offer + * again, and a quiet action-bar line reminds castaways it exists. It used to + * re-open the dialog every prompt-seconds, which was fine while the + * interstice was empty water and unbearable once it had wart to harvest, + * blazes to fight and wrecks to dive (ruled by Ben, 2026-08-07). * * @author tastybento */ @@ -64,6 +68,8 @@ public class IntersticeService { /** Player -> the destination cell they are still owed, free of charge. */ private final Map pendingDestination = new ConcurrentHashMap<>(); private final Map lastPrompt = new ConcurrentHashMap<>(); + /** Players who have already seen this visit's dialog - it shows ONCE. */ + private final java.util.Set prompted = java.util.concurrent.ConcurrentHashMap.newKeySet(); /** Players whose next warp is rigged to fail, consumed on use. */ private final java.util.Set rigged = java.util.concurrent.ConcurrentHashMap.newKeySet(); /** Player -> when they were stranded, for the arrival grace. */ @@ -335,21 +341,33 @@ public boolean isInGrace(UUID playerId) { } /** - * Offer the free re-engage to interstice castaways, and clean up. + * Offer the free re-engage to interstice castaways, and clean up. The + * DIALOG shows once per visit; after that the offer lives on the action + * bar (every prompt-seconds; 0 silences it) and behind {@code /tw go}. */ private void tick() { if (addon.getNetherWorld() == null) { return; } long now = System.currentTimeMillis() / 1000; + java.util.Set present = new java.util.HashSet<>(); for (Player player : addon.getNetherWorld().getPlayers()) { + present.add(player.getUniqueId()); + if (prompted.add(player.getUniqueId())) { + lastPrompt.put(player.getUniqueId(), now); + openReEngageDialog(player); + continue; + } + int period = addon.getSettings().getIntersticePromptSeconds(); long last = lastPrompt.getOrDefault(player.getUniqueId(), 0L); - if (now - last < addon.getSettings().getIntersticePromptSeconds()) { + if (period <= 0 || now - last < period) { continue; } lastPrompt.put(player.getUniqueId(), now); - openReEngageDialog(player); + User.getInstance(player).sendMessage("tradewinds.interstice.way-out"); } + // Leaving the interstice (or logging out) re-arms the one-time dialog + prompted.retainAll(present); hunt(); sweep(); } @@ -399,10 +417,10 @@ public void openReEngageDialog(Player player) { .body(java.util.List.of(DialogBody.plainMessage( user.getTranslationAsComponent("tradewinds.ui.interstice.body", NO_VARS)))) .build()) - // A way to put the dialog down. The offer repeats every - // prompt-seconds and the fuel is already spent, so declining - // costs nothing - but without an exit the only way to look at - // the sea was to take the warp. + // A way to put the dialog down. The fuel is already spent and + // /tw go raises the offer again whenever, so declining costs + // nothing - but without an exit the only way to look at the + // sea was to take the warp. .type(DialogType.multiAction(java.util.List.of(engage)) .exitAction(ActionButton.builder( user.getTranslationAsComponent("tradewinds.ui.interstice.stay", NO_VARS)) @@ -416,6 +434,7 @@ public void openReEngageDialog(Player player) { private void reEngage(Player player, IslandSpec target) { pendingDestination.remove(player.getUniqueId()); lastPrompt.remove(player.getUniqueId()); + prompted.remove(player.getUniqueId()); addon.getWarpService().deliver(player, target); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index bbb94b5..a2c92b9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -606,13 +606,71 @@ interstice: watchtower-grid: 1536 watchtower-chance: 0.6 watchtower-loot-table: "minecraft:chests/bastion_treasure" + # Shipwrecks: the interstice is a graveyard of ships that misjumped and never + # re-engaged. Grid size in blocks and chance (0-1) a cell holds a wreck - + # the defaults put a hull roughly every 130 blocks - a graveyard, not a + # scattering. Each wreck perches on its own reef with its top works just + # breaking the surface. 0 chance disables the graveyard. + wreck-grid: 64 + wreck-chance: 0.25 + # Fraction of wrecks (0-1) whose chests are stocked at all. The rest are + # scenery - there to see, their chests empty. Dense graveyard, scarce gold. + wreck-loot-chance: 0.3 + # The structure templates wrecks are drawn from - vanilla shipwreck variants + # by default; any structure key the server can load works. Which template, + # rotation and burial depth a wreck gets is seeded, like everything else. + wreck-templates: + - "minecraft:shipwreck/with_mast" + - "minecraft:shipwreck/with_mast_degraded" + - "minecraft:shipwreck/upsidedown_full" + - "minecraft:shipwreck/upsidedown_full_degraded" + - "minecraft:shipwreck/upsidedown_fronthalf" + - "minecraft:shipwreck/upsidedown_fronthalf_degraded" + - "minecraft:shipwreck/upsidedown_backhalf" + - "minecraft:shipwreck/upsidedown_backhalf_degraded" + - "minecraft:shipwreck/sideways_full" + - "minecraft:shipwreck/sideways_full_degraded" + - "minecraft:shipwreck/sideways_fronthalf" + - "minecraft:shipwreck/sideways_fronthalf_degraded" + - "minecraft:shipwreck/sideways_backhalf" + - "minecraft:shipwreck/sideways_backhalf_degraded" + - "minecraft:shipwreck/rightsideup_full" + - "minecraft:shipwreck/rightsideup_full_degraded" + - "minecraft:shipwreck/rightsideup_fronthalf" + - "minecraft:shipwreck/rightsideup_fronthalf_degraded" + - "minecraft:shipwreck/rightsideup_backhalf" + - "minecraft:shipwreck/rightsideup_backhalf_degraded" + # Chance per seafloor column of a piece of bottom life: glow lichen beds, + # basalt spikes, magma vents, soul-sand seeps, blackstone boulders. This is + # what keeps the interstice floor from reading as a snooker table when you + # look down through the water. 0 leaves it bare. + seafloor-clutter: 0.03 + # Mark every wreck with a soul flame: a charred basalt mast rising through the + # hull to just above the surface, blue fire on top. Without it the graveyard + # is invisible - hulls on the seabed of a dark sea read as empty water. + wreck-flames: true + # Loot tables by wreck grade. COMMON wrecks (most) carry fortress-style chest + # loot; RARE ones carry bastion finds (Pigstep, armor trims, the netherite + # upgrade template chance); TREASURE wrecks carry the bastion treasure room - + # and their second chest, the captain's locker, rolls LOCKER: piglin-bartering + # goods (ender pearls, crying obsidian, soul speed). Every chest in a wreck is + # re-pointed at these tables, which also removes vanilla's buried-treasure maps + # (there is nothing in this ocean for them to point at). + wreck-loot: + COMMON: "minecraft:chests/nether_bridge" + RARE: "minecraft:chests/bastion_other" + TREASURE: "minecraft:chests/bastion_treasure" + LOCKER: "minecraft:gameplay/piglin_bartering" # Seconds after a failed warp during which nothing in the interstice may target or # hurt the player - long enough to read the dialog and take the free way out. grace-seconds: 20 # Ghasts spawned around a stranded sailor, when any come at all. ghasts-min: 1 ghasts-max: 3 - # Seconds between offers of the free re-engage while adrift. + # Seconds between quiet action-bar reminders that the free way out exists, + # while adrift in the interstice. The re-engage DIALOG shows once per + # stranding; /tw go (or /tw warp) raises it again on demand. 0 silences + # the reminders entirely. prompt-seconds: 20 encounters: # Random mob encounters at sea - the risk that balances free rowing against diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 9e9be62..b9c7df6 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -325,7 +325,7 @@ tradewinds: re-engage: "Re-engage the warp to [name]" re-engage-tooltip: "Free - the fuel was spent when you first engaged" stay: "Stay a while" - stay-tooltip: "Close this. The offer comes round again, and the fuel is already spent." + stay-tooltip: "Close this and explore. /tw go brings the offer back whenever you are ready - the fuel is already spent." item: boat-lore-cargo: "Cargo: [used]/[slots] slots" boat-lore-fuel: "Fuel: [units] units" @@ -443,6 +443,7 @@ tradewinds: interstice: stranded: "The warp collapses! Dark water, and screaming above it." no-portal: "The frame drinks the flame and goes cold. The sea between seas does not open to fire and stone." + way-out: "[actionbar]The warp still owes you a crossing - /tw go when you are ready." encounter: puffer_shoal: "The water ahead prickles - a shoal of pufferfish across your course. Mind your hands." guardian_picket: "Guardians rise in your path." diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/IntersticeMapTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/IntersticeMapTest.java index 88409f4..05a5870 100644 --- a/src/test/java/world/bentobox/tradewinds/galaxy/IntersticeMapTest.java +++ b/src/test/java/world/bentobox/tradewinds/galaxy/IntersticeMapTest.java @@ -21,7 +21,7 @@ class IntersticeMapTest { private static final int SEA = 70; private IntersticeMap map() { - return new IntersticeMap(SEED, 256, 0.5, 9, 0.2, 1536, 0.6); + return new IntersticeMap(SEED, 256, 0.5, 9, 0.2, 1536, 0.6, 320, 0.4, 0.3); } private IntersticeMap.Shoal firstShoal(IntersticeMap map) { @@ -43,7 +43,7 @@ void testDeterminism() { assertEquals(a.towerInCell(cx, cx), b.towerInCell(cx, cx)); } // A different seed disagrees somewhere - IntersticeMap other = new IntersticeMap(SEED + 1, 256, 0.5, 9, 0.2, 1536, 0.6); + IntersticeMap other = new IntersticeMap(SEED + 1, 256, 0.5, 9, 0.2, 1536, 0.6, 320, 0.4, 0.3); boolean differs = false; for (int cx = -50; cx < 50 && !differs; cx++) { differs = !a.shoalInCell(cx, 0).equals(other.shoalInCell(cx, 0)); @@ -90,7 +90,7 @@ void testOpenWaterAvoidsFeatures() { @Test void testZeroChanceDisables() { - IntersticeMap off = new IntersticeMap(SEED, 256, 0.0, 9, 0.2, 1536, 0.0); + IntersticeMap off = new IntersticeMap(SEED, 256, 0.0, 9, 0.2, 1536, 0.0, 320, 0.0, 0.3); for (int cx = 0; cx < 50; cx++) { assertTrue(off.shoalInCell(cx, 0).isEmpty()); assertTrue(off.towerInCell(cx, 0).isEmpty()); @@ -98,6 +98,100 @@ void testZeroChanceDisables() { assertTrue(off.isOpenWater(12345, -6789)); } + @Test + void testWrecksAreSeededAndWellFormed() { + IntersticeMap a = map(); + IntersticeMap b = map(); + boolean found = false; + for (int cx = -30; cx < 30; cx++) { + var wreck = a.wreckInCell(cx, 5); + assertEquals(wreck, b.wreckInCell(cx, 5), "Wrecks must be pure functions of the seed"); + if (wreck.isPresent()) { + found = true; + IntersticeMap.Wreck w = wreck.get(); + assertTrue(w.variant() >= 0, "Variant indexes a template list"); + assertTrue(w.rotation() >= 0 && w.rotation() <= 3, "Rotation is quarter-turns"); + assertTrue(w.sink() >= 0 && w.sink() <= 2, "Burial stays shallow"); + // Jitter never leaves the cell + assertEquals(cx, Math.floorDiv(w.centerX(), 320)); + } + } + assertTrue(found, "No wreck in 60 cells at 0.4 chance - wrong seed?"); + } + + @Test + void testWreckGradesAreWeightedTowardCommon() { + IntersticeMap map = map(); + int common = 0; + int treasure = 0; + int total = 0; + for (int cx = -100; cx < 100; cx++) { + for (int cz = -10; cz < 10; cz++) { + var wreck = map.wreckInCell(cx, cz); + if (wreck.isPresent()) { + total++; + if (wreck.get().grade() == IntersticeMap.WreckGrade.COMMON) { + common++; + } else if (wreck.get().grade() == IntersticeMap.WreckGrade.TREASURE) { + treasure++; + } + } + } + } + assertTrue(total > 100, "Expected a real sample, got " + total); + // 70/25/5 by design: ordinary cargo is the rule, the prize is rare + assertTrue(common > total / 2, "COMMON should dominate: " + common + "/" + total); + assertTrue(treasure < total / 10, "TREASURE should be rare: " + treasure + "/" + total); + assertTrue(treasure > 0, "TREASURE should still exist in a real sample"); + } + + @Test + void testWreckReefLiftsTheHullToTheSurface() { + IntersticeMap map = map(); + IntersticeMap.Wreck wreck = null; + for (int cx = 0; cx < 60 && wreck == null; cx++) { + wreck = map.wreckInCell(cx, 9).orElse(null); + } + assertTrue(wreck != null, "No wreck in 60 cells"); + // The reef crests a few blocks down, so the hull perched on it rides + // mostly submerged with its top works just breaking the surface + int crest = map.wreckSurfaceAt(wreck.centerX(), wreck.centerZ(), SEA).orElseThrow(); + assertTrue(crest >= SEA - 8 && crest <= SEA - 6, + "Crest should sit a hull-height under the surface, got " + crest); + // The mound fades: beyond its radius the natural floor decides + assertTrue(map.wreckSurfaceAt(wreck.centerX() + 60, wreck.centerZ(), SEA).isEmpty()); + // And a wreck site is never open water for strandings + assertFalse(map.isOpenWater(wreck.centerX(), wreck.centerZ())); + } + + @Test + void testWreckLootIsRationed() { + // Most wrecks are scenery: only the loot-chance fraction carry gold + IntersticeMap none = new IntersticeMap(SEED, 256, 0.5, 9, 0.2, 1536, 0.6, 320, 0.5, 0.0); + IntersticeMap all = new IntersticeMap(SEED, 256, 0.5, 9, 0.2, 1536, 0.6, 320, 0.5, 1.0); + int seen = 0; + for (int cx = -50; cx < 50; cx++) { + var bare = none.wreckInCell(cx, 1); + var rich = all.wreckInCell(cx, 1); + assertEquals(bare.isPresent(), rich.isPresent(), "Loot chance must not move wrecks"); + if (bare.isPresent()) { + seen++; + assertFalse(bare.get().loot(), "Chance 0: every wreck is scenery"); + assertTrue(rich.get().loot(), "Chance 1: every wreck is stocked"); + } + } + assertTrue(seen > 10, "Expected a real sample, got " + seen); + } + + @Test + void testWreckZeroChanceDisablesTheGraveyard() { + IntersticeMap off = new IntersticeMap(SEED, 256, 0.5, 9, 0.2, 1536, 0.6, 320, 0.0, 0.3); + for (int cx = -50; cx < 50; cx++) { + assertTrue(off.wreckInCell(cx, 0).isEmpty()); + } + assertTrue(off.wrecksNear(0, 0, 10000).isEmpty()); + } + @Test void testTowersExistAndSitInTheirCells() { IntersticeMap map = map(); diff --git a/src/test/java/world/bentobox/tradewinds/galaxy/SeabedTest.java b/src/test/java/world/bentobox/tradewinds/galaxy/SeabedTest.java new file mode 100644 index 0000000..a9f2a0d --- /dev/null +++ b/src/test/java/world/bentobox/tradewinds/galaxy/SeabedTest.java @@ -0,0 +1,45 @@ +package world.bentobox.tradewinds.galaxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * The dune field (2026-08-07): the 220-block relief swells read as dead flat + * from a boat, so short dunes ride on top - and they must be OFF by default, + * or every pre-dune world's floor would shift under its generated chunks. + * + * @author tastybento + */ +class SeabedTest { + + private static final long SEED = 424242L; + private static final int SEA = 70; + + @Test + void testDunesMakeTheFloorRollWithinAGlance() { + Seabed duned = new Seabed(SEED, SEA, new SeabedConfig(6, 34, 10, 10, 16, 0.82, 12, 4)); + // A 64-block transect - about what one glance down from a boat covers + int min = Integer.MAX_VALUE; + int max = Integer.MIN_VALUE; + for (int x = 0; x < 64; x++) { + int y = duned.heightAt(x, 9000, 0); + min = Math.min(min, y); + max = Math.max(max, y); + } + assertTrue(max - min >= 3, + "A duned floor should visibly roll within 64 blocks, spread was " + (max - min)); + } + + @Test + void testZeroDunesKeepsTheOldFloorExactly() { + // The 7-arg form is the pre-dune shape: existing worlds must not shift + Seabed old = new Seabed(SEED, SEA, new SeabedConfig(14, 46, 18, 9, 26, 0.80, 20)); + Seabed explicit = new Seabed(SEED, SEA, new SeabedConfig(14, 46, 18, 9, 26, 0.80, 20, 0)); + for (int x = -200; x < 200; x += 7) { + assertEquals(old.heightAt(x, x * 3, 0), explicit.heightAt(x, x * 3, 0), + "duneHeight 0 must be byte-identical to the pre-dune floor"); + } + } +} diff --git a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java index bff8dae..920c5be 100644 --- a/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java +++ b/src/test/java/world/bentobox/tradewinds/generator/ChunkGeneratorWorldTest.java @@ -70,7 +70,7 @@ public void setUp() throws Exception { // Default: a featureless interstice (no shoals, no towers) - pure dark sea when(addon.getIntersticeMap(org.mockito.ArgumentMatchers.anyLong())) .thenReturn(new world.bentobox.tradewinds.galaxy.IntersticeMap(SEED, 256, 0.0, 9, 0.2, 1536, - 0.0)); + 0.0, 320, 0.0, 0.0)); } private WorldInfo worldInfo(Environment env, long seed) { @@ -209,7 +209,7 @@ void testIntersticeShape() { void testWartShoalsBreakTheIntersticeSurface() { // Shoals on at defaults: find one and generate its chunk world.bentobox.tradewinds.galaxy.IntersticeMap map = new world.bentobox.tradewinds.galaxy.IntersticeMap( - SEED, 256, 0.5, 9, 0.2, 1536, 0.0); + SEED, 256, 0.5, 9, 0.2, 1536, 0.0, 320, 0.0, 0.0); when(addon.getIntersticeMap(org.mockito.ArgumentMatchers.anyLong())).thenReturn(map); world.bentobox.tradewinds.galaxy.IntersticeMap.Shoal shoal = null; for (int cx = 0; cx < 30 && shoal == null; cx++) { diff --git a/tradewinds-interstice-plan.md b/tradewinds-interstice-plan.md index 0c2dd98..f97c01f 100644 --- a/tradewinds-interstice-plan.md +++ b/tradewinds-interstice-plan.md @@ -43,8 +43,12 @@ intended: the most lawless water feeds the most dangerous market. | Wither skeleton skull | nether star → beacon | wither watchtowers (5) | | Netherrack / nether bricks | building, watchtower salvage | interstice crust (already); towers (5) | | Crimson/warped wood, shroomlight | building | groves on grand shoals (1) | -| Netherite scrap / templates | netherite gear | LOOT ONLY: tower chests, encounter booty, secondhand shelves — never mineable | -| Dragon's breath, ender pearls, shulker shells, echo shards | lingering potions, endgame | TRADE ONLY: "goods from beyond the horizon" — rare secondhand/booty items, never gatherable. No End, ever. | +| Netherite scrap / templates | netherite gear | LOOT ONLY: tower chests, wreck chests (7), encounter booty, secondhand shelves — never mineable | +| Diamonds, horse armor, saddles, obsidian | gear, riding | wreck chests (7): COMMON grade = the vanilla fortress table | +| Pigstep, armor trims, netherite upgrade template | cosmetics, netherite | wreck chests (7): RARE grade = the vanilla bastion table | +| Crying obsidian, soul speed, fire res potions | respawn anchor, soul-sand travel | wreck chests (7): captain's locker = piglin bartering table | +| Dragon's breath, shulker shells, echo shards | lingering potions, endgame | TRADE ONLY: "goods from beyond the horizon" — rare secondhand/booty items, never gatherable. No End, ever. | +| Ender pearls | ender eyes, teleport | mostly trade; SMALL amounts loot-findable in the captain's locker (ruled by Ben 2026-08-06: the wreck loot mirrors vanilla nether loot, and bartering includes pearls — locker quantities are flavor, not a faucet) | Already covered in the overworld and unchanged: redstone, gunpowder, spider eye, sugar, rabbit's foot, pufferfish, golden carrot, phantom membrane @@ -95,6 +99,24 @@ destination, which is a route" — mineable debris collapses the netherite trade); no piglin bartering (cut for scope; revisit only if a use appears); no End portals, stronghold, or elytra, ever. +7. **The ship graveyard (added 2026-08-06, BUILT).** Vanilla shipwreck + templates seeded across the interstice seabed (`IntersticeMap.Wreck`: + grid 320, chance 0.4, variant/rotation/burial from the cell hash) — + ships that misjumped and never re-engaged, which is the entry-by-accident + lore told in scenery. Placed by the decorator via + `Bukkit.getStructureManager()` into the `LimitedRegion` (center-anchored + so even the 28-block hulls fit chunk+buffer; the RegionAccessor overload + of `Structure.place`). Every chest in a wreck is re-pointed by grade — + COMMON 70% → `minecraft:chests/nether_bridge` (the fortress list: + diamonds, horse armor, saddles, obsidian, wart), RARE 25% → + `minecraft:chests/bastion_other` (Pigstep, trims, upgrade-template + chance), TREASURE 5% → `minecraft:chests/bastion_treasure`, with any + second chest on a TREASURE wreck becoming the captain's locker → + `minecraft:gameplay/piglin_bartering` (pearls, crying obsidian, soul + speed). Re-pointing also kills vanilla's buried-treasure maps, which + would point at structures this ocean does not generate. All tables and + the template list are config (`interstice.wreck-*`). + ## Nether portals (decided 2026-08-05) Reality check from play: ruined portals are common ocean structures (three From b55e0fb74a83ae2b652757444c90b9e26bb15bf4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 7 Aug 2026 07:41:06 -0700 Subject: [PATCH 097/112] Keep the design docs out of the public repo All internal design documents (spec, plans, progress log, manual test plan) now live in docs/, which is git-ignored: the public repo carries the code, CLAUDE.md points at the docs' new local paths. The files remain in the working copy only. Note: everything up to this commit is still in history - if the repo goes public with history intact, the old copies are visible there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- # Boat mechanics.md | 127 -- .gitignore | 3 + CLAUDE.md | 15 +- TESTING.md | 518 ----- TRADEWINDS_SPEC.md | 218 -- docs/PROGRESS.md | 2821 -------------------------- docs/TESTING-archive.md | 1301 ------------ files.zip | Bin 13665 -> 0 bytes files/tradewinds-design-decisions.md | 82 - files/tradewinds-hold-plan.md | 132 -- files/tradewinds-overview.md | 33 - tradewinds-design-decisions.md | 141 -- tradewinds-dev-plan.md | 158 -- tradewinds-hold-plan.md | 185 -- tradewinds-interstice-plan.md | 178 -- tradewinds-overview.md | 33 - tradewinds-salvage-plan.md | 220 -- 17 files changed, 12 insertions(+), 6153 deletions(-) delete mode 100644 # Boat mechanics.md delete mode 100644 TESTING.md delete mode 100644 TRADEWINDS_SPEC.md delete mode 100644 docs/PROGRESS.md delete mode 100644 docs/TESTING-archive.md delete mode 100644 files.zip delete mode 100644 files/tradewinds-design-decisions.md delete mode 100644 files/tradewinds-hold-plan.md delete mode 100644 files/tradewinds-overview.md delete mode 100644 tradewinds-design-decisions.md delete mode 100644 tradewinds-dev-plan.md delete mode 100644 tradewinds-hold-plan.md delete mode 100644 tradewinds-interstice-plan.md delete mode 100644 tradewinds-overview.md delete mode 100644 tradewinds-salvage-plan.md diff --git a/# Boat mechanics.md b/# Boat mechanics.md deleted file mode 100644 index b77f955..0000000 --- a/# Boat mechanics.md +++ /dev/null @@ -1,127 +0,0 @@ -# Boat mechanics - -## One Boat Rule: - -Players only have one boat at a time or no boat. - -## Boat Inventory GUI -The inventory of a boat is accessible by sitting in the boat and opening inventory, whether it has a chest on it or not, or sneaking up to the boat and right clicking on it, or by having the boat in your inventory and right clicking on it. - -The boat inventory is its own GUI panel (uses the BentoBox Panel API) with up to 21 slots available for cargo, varies by the size of the boat, a TNT icon which is used to destroy items by dragging and dropping them to it, and a set of fuel slots. - -XTXXXXXXX -XCCCCCCCX -XCCCCCCCX -XCCCCCCCX -XXXXXXXXX -XFFFFFFFX - -T = TNT, with lore saying Drag cargo here to destroy them. -C = Cargo slot -F = Fuel slot - pale red glass panel if not occupied with lore saying "Fuel Slot" -X = Light blue glass panel border, no lore. - -If the boat only has 2 cargo slots, then only two are blank. The rest of the slots are X. - -Fuel can be dragged and dropped into the GUI from the player's inventory. If it is dropped anywhere, it will take up a Fuel slot, and stack, if possible. Fuel can be taken out of the GUI or moved within the gui. Fuel will not be destoyed if dragged to the TNT. Only cargo. - -## Boat Inventory Management -In general, items are stored in optimal stacks and not spread out, so the number of slots is always optimized. - -### Removing items - -You can destory (jetison) items. This removes them from the game and they are not thrown out. Picking up the items and dropping them on the TNT item will destroy them. Trying to transfer items out of the inventory or throw them does not work. - -### Adding items - -It is possible to add any item you want to the hold except for pouches or shulker boxes. Howeverm it;s a one-way transfer. If you cannot sell it, it will just take up room, and you'll need to destroy it. Items that hold other items are not allowed because they expand the hold size. - - -## Scenarios - -### Upgrading boat (upgrading inventory size) - -There are three ways you can obtain a boat: - -1. Buy a new boat from the shop -2. Craft a boat -3. Pick up a boat item - -#### Shops - -You can only buy boats that are bigger than what you have. They cost more based on how many slots of inventory they provide. So an Mangrove Boat with 14 slots is 7x the cost of a Bamboo Raft with 2 slots. When you buy it, it replaces any boat in your inventory, or gives you a boat if there isn't one in your inventory. Remember, it's actually your personal inventory size that its changing. As such when you buy a bigger boat, all the inventory in the previous boat is now in that inventory (it actually does not "move", all that has happened is that your inventory slot size has increased). - -Examples: -1. Player has no boat. He buys a Birch Boat. He now has 6 slots that are empty. -2. Player has a Pale Oak Boat with 18 slots in his inventory. They are full of granite. He upgrades to a Cherry Chest Boat, which as 20. When he opens the Cherry Chest Boat's inventory he will see 18 slots full of granite, and 2 empty slots. The Pale Oak Boat is removed from his inventory and replaced. -3. Player has a Birch Boat. They cannot buy anything smaller at the shop. - -# Crafting a boat - -This is the same as buying a boat, except the player has done it by themselves. If the boat crafted is smaller than the one they have, then it cannot be crafted. Players can take their current boat, and add a chest to it at any time, if they can get a chest or make a chest. - -### Dropping and picking up boats - -Boats are items and can be dropped. This might be because a player throws it, or dies somehow, or the boat is broken somehow, see edge cases. When the boat is an item, the inventory that was "in" the boat goes with the item. It is now freely available for a player to pick it up. The rules are as follows, and are similar to the shop, but different because the boat item may have items in its inventory: - -1. Boat items have a time-to-live. If they are not picked up within a period of time, they are auto removed from the game. See edge case section for how to handle server restarts. -2. If the boat item is a higher rank (bigger) than the boat the player has, then it immediately replaces the player's current boat and the player's boat inventory size becomes bigger as result. -3. If the boat is smaller, they keep their current boat. -4. Inventory from the captured boat is transfered to the player's boat inventory. -5. The game will try and move in the most valuable items from the salvaged boat first. The player's current inventory remains. -6. If the player's inventory is full, the player is told that not all the items could be transfered and transfer stops. -7. Items that cannot be transfered stay in the item boat item to be picked up by someone else, or by the player if they jetison (destroy) items in their boat's inventory to make space, or go and trade items to make room and then return. -8. If the item boat's TTL expires, it is removed from the game along with any items it had in it. - -#### Examples - -1. Player finds a boat item. The player has a Jungle Boat (8 slots, with 4 of them filled) and the item is a Bamboo Raft (2 slots with items in one of the slots). The player picks up the bamboo raft item and all the items from it transfer to the Jungle Boat and the bamboo raft is removed from the game. -2. Player finds a boat item. The player has a Jungle Boat (8 slots, with all 8 of them filled) and the item is a Bamboo Raft (2 slots with items in one of the slots). The payer cannot pick up the bamboo raft item because their slots are full. -3. Player finds a boat item. The player has a Jungle Boat (8 slots, with 7 of them filled) and the item is a Bamboo Raft (2 slots full with items in both of the slots). The player receives the contents of one of the slots, and the bamboo raft remains with one slot of items in it. The player cannot pick up any more. -4. Player fills up their Oak Boat with 3 stacks of raw iron by buying it at the plaza. They throw it to the ground in the plaza, so they now have no boat. Another player can pick it up. This enables players to transfer bought items between each other. Not an exploit, but allowed. - - -#### Edge cases -1. If the item is subsequently destroyed somehow, then the inventory is lost, e.g., the item entitiy is removed by another plugin. -2. Broken boat e.g., by a trident, hitting something, etc.. Boats must not be destoyed except by being thrown in to lava. If a drowned throws a trident and hits the boat, it should just become an item. -3. If a mob picks up the boat, e.g., zombie (not sure if this is possible), then it'd be great if the inventory can be kept if the zombie is killed, but I'm not sure if this will be possible. -4. Boat items have a time to live and this should survive server crashes or restarts. I recommend the time to remove the item is stored in the database as a future timestamp. A period check is done and if any item is overdue, then it is removed from the game by UUID reference. -5. When a player dies, their boat *must* always be a dropped item. It should never be destroyed, unless they fall in lava. -6. If a player is in a situation where there is too much inventory for their hold size, then they have to pick what to keep. - - - -# Multiple boats - -Starting point: -Player has a chest boat with some inventory in it. - -Scenarios: -1. It is left somewhere as an entity - e.g., moored by the dock -2. It is in the player's inventory -3. It has become an item, and is floating in the ocean, or on the ground. - -What happens in these situations for all the above scenarios? -1. Player picks up or is given a bamboo raft boat item. The bamboo raft has no inventory in it. -2. Player picks up or is given a bamboo raft boat item. The bamboo raft has some inventory in it. - - -a) yes, you pick up the goods from the smaller hull, if there any any, otherwise, the hull just stays on the ground (and is ultimately removed from the game by the TTL). - -For the others, I think we need some rules about boat proximity to the island and trading. i.e., if your boat is not in the island space (this is a BentoBox API check) then you cannot trade at the island. You must have a boat somewhere within the island space. - -Next, what happens if you abandon a boat, that is you leave the boat unattended? - -1. Running /tw chart will show you where your active BOAT is, similarly to the DOCK option. This works anywhere in the world. -2. If you leave it unattended in any island space, it is protected from other players jumping into it or it being broken, even by mobs. This works for any island except anarchy islands. -3. If you leave a boat unattended outside of any island space, then it is fair game for capture. It is still technically yours but cannot be used for any trading because it is not in an island space. If you get into another boat, then it is no longer your main boat, and the /tw chart will show OLD BOAT until either the boat is taken by another player, or destroyed somehow. This is how players can recover their boat if they die. For example, they leave their boat at a dock, get killed by mobs on the island, go back to spawn, get a bamboo raft, and row back to the old boat, and then get into it. -4. If another player jumps into an unattended unprotected boat, then they acquire the boat and everything in it. Their current boat, if it exists, shows on the chart as OLD BOAT. -5. Being a passenger in a boat does not take over a boat. - -Special situation: logging off while in your boat, or when the boat is moored somewhere. - -1. If your boat is in a protected area, then it is protected while you are logged out. Nothing to worry about. -2. If the boat is not in a protected area or the island is anarchic, your boat stays in the world, but someone could take it. If that happens, then when the player logs in they should get a chat message telling them that it was taken/stolen. If they have no boat and are dropped into water as a result, give them a bamboo raft to enable them to paddle somewhere. - -If players want to avoid this, they can will have to jump out of the boat, break it, and store it in their inventory before logging out if they want it to be protected. - diff --git a/.gitignore b/.gitignore index fb2769a..654eb48 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ target/ .DS_Store database/ database_backup/ +docs/ +files/ +files.zip diff --git a/CLAUDE.md b/CLAUDE.md index 4d86a9a..1f4dafa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,18 +31,21 @@ model are indistinguishable from bugs. Say so when handing a build over. **TradeWinds** is a BentoBox GameModeAddon for Paper **26.2**: an endless procedurally generated ocean of NPC trading islands — buy low, sell high, -smuggle, hunt bounties, turn pirate. Requirements live in `TRADEWINDS_SPEC.md` +smuggle, hunt bounties, turn pirate. Requirements live in `docs/TRADEWINDS_SPEC.md` (the authoritative spec; read it first). Design rationale is in -`tradewinds-design-decisions.md`, the stage plan in `tradewinds-dev-plan.md`. -`docs/PROGRESS.md` records what is done and pitfalls hit; `TESTING.md` is the +`docs/tradewinds-design-decisions.md`, the stage plan in `docs/tradewinds-dev-plan.md`. +`docs/PROGRESS.md` records what is done and pitfalls hit; `docs/TESTING.md` is the manual test plan, ordered by risk (Tier 1 smoke first) - add new checks to the right tier, not to the end; `docs/TESTING-archive.md` is the old per-stage list, history only. Update PROGRESS and TESTING as features land. +**`docs/` is git-ignored** (ruled 2026-08-07: internal design docs stay out of +the public repo) — the files live only in this working copy, so never delete +the folder, and don't expect it in a fresh clone. Load-bearing design rules (from the spec — breaking one is a bug): trading transacts only against the **virtual hold**; **trader-bought** cargo leaves the hold only by sale or destruction (player-loaded salvage may be -withdrawn — narrowed 2026-08-03, see `tradewinds-salvage-plan.md`, and the +withdrawn — narrowed 2026-08-03, see `docs/tradewinds-salvage-plan.md`, and the distinction is a PDC mark, `travel/CargoMark`); everything downstream of the galaxy seed is a pure function of (seed, position) with **no Bukkit imports** (package `world.bentobox.tradewinds.galaxy`), unit-tested headlessly; no End world ever; @@ -50,8 +53,8 @@ interstice re-engage is always free; police mobs never drop loot. ## The boat/hold model (read before touching cargo or boats) -`tradewinds-hold-plan.md` is **normative** here — it wins over the spec on -hold and boat mechanics. `tradewinds-salvage-plan.md` is normative for the +`docs/tradewinds-hold-plan.md` is **normative** here — it wins over the spec on +hold and boat mechanics. `docs/tradewinds-salvage-plan.md` is normative for the salvage economy, the NBT-aware hold and price discovery — read it before touching pricing, drift or hold contents, because it deliberately narrows the one-way-cargo rule below to trader-bought cargo only. diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index adfa4f9..0000000 --- a/TESTING.md +++ /dev/null @@ -1,518 +0,0 @@ -# TradeWinds — Manual Test Plan - -What only a live server can prove. Automated coverage lives in `src/test` -(248 tests); this is the rest. - -**Ordered by value, not by stage.** Work top to bottom and stop when you run -out of time — everything above the line you reach is more likely to be broken, -or worse to be broken, than everything below it. Tier 1 is fifteen minutes and -catches what makes the game unplayable; Tier 6 is polish. - -The pre-2026-08-02 stage-by-stage list lives in `docs/TESTING-archive.md`. It -covers a lot of mechanics that no longer exist (pouches, customs stamps, -carried expanders, the chest boat as a hold) — read it for history, not for -coverage. - -## How to run - -```bash -./scripts/deploy.sh # builds, then installs - refuses while the server is up -``` - -The script will not overwrite a running server's jar: replacing it live -invalidates the plugin classloader and takes the server down inside chunk -generation. Stop the server first; the guard also waits for `latest.log` to go -quiet for a minute. - -**Clean slate:** after any change to the hold, boat or galaxy model, delete the -`tradewinds_world*` folders **and** the `BoatHold`, `TWPlayerData`, -`TWIslandData` database folders. Stale records from a previous model are -indistinguishable from bugs. - -Two accounts are needed for part of Tier 3. Everything else is single-player. - ---- - -# Tier 1 — Smoke test (~15 min) - -If any of these fail, stop and report: the game is unplayable and the rest is -noise. - -## Boot - -- [ ] Server starts with no TradeWinds errors or warnings. (Duplicate-key YAML - warnings are a build bug the suite should have caught — mention any.) -- [ ] `bbox version` lists TradeWinds ENABLED alongside the other gamemodes. -- [ ] `tradewinds_world` and `tradewinds_world_nether` exist; no `_the_end`. -- [ ] `addons/TradeWinds/config.yml` has the current sections: `galaxy`, - `boats`, `border`, `economy`, `travel`, `illegal-trade`. -- [ ] Other gamemodes still work (create or visit an AcidIsland island). - -## The first ten minutes of a new player - -- [ ] `/tw` puts you at the spawn port: plaza, stalls, villagers, dock. Not in - the treetops, not in the seabed. -- [ ] You get an **Oak Boat**, and the message names it, its 3 cargo slots and - the coal in its fuel tank. -- [ ] The boat item's lore reads `Cargo: 0/3 slots`, `Fuel: 64 units`, - `Right-click to open the hold`. -- [ ] Right-click the boat item: the hold opens — TNT slot, 3 open cargo slots, - the rest grey, 7 fuel slots holding the coal. -- [ ] Place the boat on water and board it: **no dialog**, no OLD BOAT, coal - still aboard. (Historically the most-broken path in the game — go slowly.) - -## The core loop - -- [ ] Open the market at the plaza and buy cargo: it lands in the hold, money - leaves, slots fill. -- [ ] Row to the island border: a **red** particle curtain, and the warp dialog - fires as you touch it — not 30 blocks early. -- [ ] Warp: you and the boat arrive facing the **dock**; hold forward and you - reach it. -- [ ] Sell the cargo at the new island at a different price than you paid. -- [ ] **Notable goods resurface elsewhere.** Sell an enchanted tool at a port, - then check the *neighbouring* ports' Secondhand shelves: it appears at one - of them, not at the port you sold it to. Ordinary cargo never appears. -- [ ] **The shelf is not a money printer.** A shelf item costs visibly more than - the same port would pay you for it. -- [ ] **A full hold does not eat a listing.** With no free slots, try to buy a - shelf item: refused, and the item is still on the shelf. -- [ ] Shelf items cannot be withdrawn from the hold afterwards (they are bought - cargo), and listings disappear after `resale-ttl-hours`. -- [ ] **The counter says when to stop.** On the sell page, a good's tooltip states - how many more the port will take at that price. Sell that many: the price - drops and the depth reads near zero. -- [ ] **A fresh player can run every player command.** `/tw prices`, chart, - go, starchart, restart, fine - none says "no permission" without an admin - having turned it off. -- [ ] **The logbook is OFF by default.** `/tw prices` is not a command, and no - "Harbour report" button appears at any market. (The checks below apply - only with `economy.price-logbook-enabled: true`.) -- [ ] **The logbook only knows where you have been.** `/tw prices` on a fresh - player says the logbook is empty. Open a port's market, run it again: that - port appears with an age. A port you have only *charted* never appears. -- [ ] `/tw prices metals` sorts ports by what they paid for metals, best first. -- [ ] **Harbour reports fill the logbook.** Buy one at a high-tech port: several - nearby ports appear in `/tw prices` at once, money leaves, and a low-tech - port offers a smaller report (or none). -- [ ] **The hold works both ways.** Mine cobblestone on an islet, load it into - the hold, then left-click it in the hold window: it comes back to your - inventory. With a full inventory the overflow drops at your feet, never - vanishes. -- [ ] **Bought cargo cannot be withdrawn.** Buy cargo at a market, then try to - left-click it out of the hold: refused, with a message saying it leaves by - sale or by the TNT. Selling it still works. -- [ ] **Bought and mined goods do not merge.** Buy 5 diamonds and mine 20: they - occupy separate slots, and only the mined 20 can be withdrawn. -- [ ] **Fuel moves by mouse.** Pick up coal in the hold window (left-click), - drop it on the fuel row: it fuels. Right-click on the fuel row feeds one - lump at a time. Works with coal BOUGHT at a market, not just mined coal. -- [ ] **The cursor cannot dupe.** Pick up a stack and press Escape: the window - closes, nothing lands in your inventory, and the cargo is still in the - hold. Try dragging the held stack across your own inventory: refused. -- [ ] Hold gestures: left-click picks up, drop on fuel row fuels, drop in your - inventory takes ashore (bought cargo refused), drop elsewhere puts back; - shift-click is the quick route; right-click selects for the TNT. -- [ ] **Boat capacity matches the ladder.** An oak boat's hold says **3 slots**, - a bamboo raft 2, a pale oak chest boat 21. If any reads a multiple of ten, - `config.yml` has drifted from the code defaults - `ConfigAgreementTest` - should have caught it. -- [ ] **Money reads "$1,728" everywhere.** No price anywhere shows cents, a - doubled symbol, or a currency name - check the market pages, the status - line, fines, and the shipwright. -- [ ] **The sell page tells identical goods apart.** With one enchanted and two - plain iron swords aboard, the sell list shows two rows: "Iron Sword ✦ x1" - and "Iron Sword x2", the enchanted one priced higher. No row shows a raw - locale key like `tradewinds.ui.market.item-enchanted`. -- [ ] **Picking a good shows it and prices it live.** Click a row: its icon - appears (hover for the real tooltip - enchantments, damage), with the unit - price and how much more the port will take. Sell 1: the page stays open and - the price and depth **move**. Sell the last one and you drop back to the - list. -- [ ] **NBT survives the hold, and a restart.** Load an enchanted, a renamed and - a half-broken tool into the hold. Each takes its own slot and keeps its - name, enchantments and damage bar. Restart the server: all three are still - exactly what they were. (This cannot be unit-tested - ItemStack - serialisation does not work headlessly.) -- [ ] **Condition and enchantments move the price.** A worn tool sells for - visibly less than a mint one; a Silk Touch pick for more than a plain one; - a Potion of Strength II for far more than a water bottle. Two differently - enchanted swords appear as two separate rows on the sell page. -- [ ] **The TNT destroys what you picked.** With both a plain and an enchanted - sword aboard, select the enchanted one and destroy it: the plain one is - still there. -- [ ] **Scavenged loot sells.** Kill a few mobs on an islet, load bones, string, - rotten flesh and gunpowder into the hold, and sell at any port: every one - of them has a price, visibly lower than a trade good of similar book value. -- [ ] `/twadmin priceaudit` writes `price-audit.txt` to the addon folder. Read - the UNPRICEABLE list: anything a player could plausibly acquire and carry - wants a base price adding. -- [ ] **A backwater refuses treasure.** Carry something rich and exotic (totem, - nether star) into a low-tech port: it is absent from the sell page, and - selling it by any other route says the port has no use for it. The same - item sells at a high-tech island. Check a *low-tech luxury* island still - buys diamonds - trade goods are exempt from this gate. -- [ ] **Dumping junk does not move honest cargo.** Note a port's iron price, sell - it a boatload of dirt and rotten flesh, and check the iron price is - unchanged - salvage drifts in its own pool. -- [ ] **Selling in saturates a port by value, not volume.** Sell ~7 diamonds (or - 12 emeralds) at one island: the unit price should fall to the floor and - stop falling. Selling a few hundred wheat should move it about as much as - those 7 diamonds did, and a boatload of cobblestone barely at all. - Prices recover after ~3 hours of real time. -- [ ] `/tw chart` raises holograms: island names and a DOCK marker, and no BOAT - marker while you are sitting in the boat. - ---- - -# Tier 2 — The hold (~20 min) - -The hold is virtual and one-way. Any route that gets cargo out unintentionally -is a duplication bug, which is the worst kind. - -- [ ] Click items in your pack to deposit: fuel to the fuel row, everything - else to cargo. -- [ ] **Refused:** bundles, shulker boxes, chests, barrels, and any boat. -- [ ] **Nothing extracts cargo.** In the hold GUI try shift-click, number keys, - double-click-gather, drag-split, cursor swap, drop key. All must fail. -- [ ] Fuel *does* come back out on click, and fuel-valued **cargo** (coal - bought at market) moves to the fuel row when clicked. -- [ ] Select a cargo stack, click the TNT: destroyed, not dropped. -- [ ] Cargo consolidates: 40 + 30 wheat is 2 slots, not 2 spread stacks. -- [ ] Fill the hold, then buy more: refused with a message and no money taken. -- [ ] Deposit fuel, close the GUI, hover the boat item: the lore has updated. -- [ ] In a **chest** boat, press the inventory key while riding: the hold - opens. (A plain boat cannot do this — Minecraft never tells the server.) - ---- - -# Tier 3 — Boats: ownership, capture, loss (~30 min, two players) - -The newest and most bug-prone system. Every check here has had a real bug -behind it. - -## One boat - -- [ ] The shipwright lists only boats **bigger** than yours, and only up to the - island's tech level. -- [ ] With **no** boat, buy a Bamboo Raft: item in your pack, money gone once, - hold opens with 2 slots. -- [ ] With a boat **at the port**, buy a bigger hull: same hold, same cargo, - more slots — and the hull you ride or carry visibly becomes the new type. - No confirmation dialog — a trade-in takes nothing from you. -- [ ] **The yard always sells** (rule change 2026-08-05): with your boat an - ocean away (die far off, respawn at spawn), the TL1 shipwright still - lists the raft and smaller hulls. Buying one shows the capture-style - confirmation, then: new boat in your pack, old boat UNOWNED where it - lies (chart still marks it, plate reads UNOWNED, another player can - take it, cargo aboard). Declining costs nothing. -- [ ] **Moored refit** (regression, 2026-08-04): exit the boat at the dock, - shove it a dozen blocks so it drifts, walk to the trader, buy a bigger - hull — the moored boat at the dock visibly becomes the new type, name - plate intact. Also works if the hull lies about as a dropped **item**. -- [ ] With your boat **elsewhere** (or moored beyond the island's protection - range), the yard refuses the refit and says to bring the ship in — and - takes no money. -- [ ] Craft a bigger boat **by clicking the result** (not shift-clicking): the - item has lore and right-click opens the hold. Repeat with shift-click. - Crafting a smaller one is refused. - -## Finding a hull - -- [ ] Walk over an unowned boat item while you own a boat: one dialog (not one - per tick) offering **Take the boat**, **Take only the cargo**, **Leave it**. -- [ ] "Take only the cargo" appears only when your own boat is within 200 blocks - and the hull carries something; with your boat on another island the - option is gone and the dialog says why. -- [ ] **Nothing teleports:** with your boat far away, no route moves cargo to it. -- [ ] Take a hull while carrying your own: you end with **one** boat item, all - the cargo in it, and the old hull on the ground. Never two in the pack. -- [ ] Immediately after that swap, standing on the shed hull does NOT re-open - the dialog; after ~5 seconds it does. Dropping and re-picking your OWN - boat works at once, even inside that window. -- [ ] Right-clicking a boat item that is not your ship does nothing. -- [ ] Boarding an unattended hull offers the same three answers. - -## Capture and loss (two players) - -- [ ] Player 2 online when player 1 takes their boat: told immediately. -- [ ] Player 2 then has no boat: no cargo slots at market, **no** low-fuel - warnings, **no** OLD BOAT on the chart (it was taken, not abandoned). -- [ ] Player 2 can still use the **outfitter and shipwright** — buying a hull is - the only way off the island. Only Sell/Buy cargo are withheld. -- [ ] Restart the server: no repeat "taken while you were away", and player 1 - still owns the boat. -- [ ] Player 2 offline when it happens: told once, at next login. - -## Protection and abandonment - -- [ ] Boat plates read the owner's name, or UNOWNED; hidden while ridden. -- [ ] Another player's unattended boat in protected island space cannot be - boarded or broken — mobs included. On an ANARCHIC island it can. -- [ ] Unowned hulls are takeable anywhere, even at a safe dock. -- [ ] Chart markers point only at boats you must travel to: never one you carry, - never one under you, never one within ~32 blocks. -- [ ] `/tw chart list` names both boats with coordinates and distance, or says - plainly that you have none. - -## Death and recovery - -- [ ] Die: the boat stays floating where it was and is still yours. -- [ ] **Die CARRYING the boat item** (regression, 2026-08-06): the chart's - marker moves to the death site, not wherever the boat was last placed. - The console logs "went down with " with coordinates. The dropped - hull never despawns on vanilla's clock; walk back days later and the - kit is still there (unloaded chunks freeze item timers). -- [ ] Respawn with the ship far away: you are lent a Bamboo Raft. Board it → - confirmation → your real boat becomes OLD BOAT. -- [ ] Row back and board it: yours again, and the marker clears. -- [ ] Lava is the only thing that destroys a boat and its cargo. -- [ ] Log out in open water: another player can take the boat. Log out inside - protected space: safe. - -## Duplicates and the logbook (2026-08-06) - -- [ ] **Creative placement** (regression): in creative, place your stamped - boat — the item leaves your pack (vanilla would keep it). No route to - two items with one boat-id. -- [ ] Walk over a duplicate of a hull you already carry (spawn one with an - old dupe if any survive): it dissolves — cancelled pickup, item gone, - "duplicate hull dissolved" in the console. -- [ ] **Outright purchase while carrying your old boat**: buy any hull with - the old one in your pack — the old hull drops at your feet UNOWNED - (never rides along in the pack), and no swap-back dialog fires as you - stand on it. -- [ ] Break the pirates' boat after winning an encounter: it splinters — - no item, no plate, and `/twadmin boat` shows no new records for anyone. - Boarding it never offers capture. -- [ ] The console carries a `Boat ...` INFO line for: place, break, pickup, - claim, demote, refit, purchase, death. `boats.logbook: false` silences - them all. -- [ ] `/twadmin boat `: both records with cargo, fuel, last-seen and - avatar state ("afloat right there" / "in X's pack" / "not in any loaded - chunk"). `restore` refuses while the hull is loaded or carried; with the - boat genuinely lost it hands a stamped item, hold contents intact. - ---- - -# Tier 3.5 — Seafarer ranks & islet claiming (~15 min, second account for two checks) - -New in Stage 7a. Claiming writes real BentoBox islands, so a bug here is a -data bug - test on a throwaway world first. - -- [ ] `/tw rank` on a fresh account: **Deck Hand**, 5 islands charted (the - starter cluster), next rung Cabin Scout at 6. -- [ ] Chart a sixth island (sail into its waters): promoted to Cabin Scout, - and you appear on the `/tw rank` top-ten board. -- [ ] `/tw claim` at sea, at a port, and ashore on a trading island: all - refuse with "no wild islet underfoot". -- [ ] On a wild islet below the rank gate: refused, message names Tide - Captain and 36 islands, no money taken. -- [ ] `/twadmin rank ` reports rank, effective and real counts; - `/twadmin rank tide-captain` promotes (check `/tw rank` and the - board agree); `... 0` demotes to Deck Hand; `... reset` returns to the - real chart. Tab-complete offers players, then rank slugs. -- [ ] Gate passed (`/twadmin rank tide-captain`) but broke: refused - with the price, no money taken. -- [ ] With rank and money: **Land ho!** - money gone exactly once, and - whatever was already built on the islet is untouched. -- [ ] Protection is islet-sized: you can build ashore and just offshore; a - second account cannot; a few boat-lengths out the second account can - build again (open ocean stays free). -- [ ] Second account standing on the claimed islet: `/tw claim` refuses with - "already claimed". The owner on a fresh islet: refused - one island - per player. -- [ ] Die without a bed: owner respawns on the claim (regression 2026-08-05: - the spawn-safety listener used to override this); an islandless - account respawns at the spawn port. Both still get the loaner raft - when their own boat is out of reach. -- [ ] `/tw sethome` on your islet sets home (respawn lands there); - `/tw home` steps to it from across the islet. Both refuse at sea, at a - port, and on someone ELSE'S island - a team member on the island can - use both, a visitor can use neither. -- [ ] `/tw go` is context-sensitive: on your island it steps you home; at - sea (with an island) it prints the bearing AND toggles the course bar; - islandless at sea it still refuses; from the lobby it still returns - you to the water you left. -- [ ] **Course bar** (7b): by day it names only the direction; wait for - night and it adds the exact distance, and the bar fills as you close. - `/tw go` again turns it off. Entering an island's waters swaps to the - island bar; making landfall on your OWN island clears the course with - a message. -- [ ] **Home warp** (7b): at any port border, the dialog's FIRST entry is - ⌂ your island, normal fuel price. A teammate sees it too; a stranger - never does. Warp home: arrive offshore facing the islet, no dialog - reopening on arrival. Row OUT across your claim's border: the dialog - offers your charted ports. With `/twadmin warpfail`, a failed home - warp drops you in the interstice and the free re-engage still says - your island's name. -- [ ] **Ship's compass** (7b): at a FISHING outfitter, a member buys a - compass - it arrives named Ship's Compass and points at their islet - from across the sea (F3 check the direction). An islandless buyer - gets a plain compass pointing at the spawn port. -- [ ] **Unclaim** (7b): refuses with a team member still aboard; owner alone - gets a confirmation, then the island record is gone - builds stay, - protection gone, and a second account can claim the same islet - (builds intact). No refund. `/tw unclaim` at the spawn island refuses - ("not a claim"). -- [ ] Claim an islet within sight of a port: either it succeeds, or it - refuses with the "port's waters" message - and refusal costs nothing. -- [ ] Restart the server: the claim survives, protection intact, and the log - shows no "distance mismatch" or duplicate-island complaints. - ---- - -# Tier 4 — Economy depth (~20 min) - -- [x] Every price on every chart is a **whole coin** - no cents anywhere - - and the market's figures match your balance line's formatting (both come - from the server economy's own formatter now). -- [ ] Buying then selling the same good at the same island always LOSES money, - including on the cheapest goods (sand, stone, kelp) where rounding is - proportionally largest. -- [x] Prices differ by island **type** (a farm sells food cheap) and by **tech** - (high tech sells metals cheap, pays more for ore). -- [x] Selling a lot of one category into one island visibly depresses its price, - and it recovers over an hour. -- [x] Tech shows everywhere an island is named: market, chart, warp tooltip, - nav bar. -- [ ] Contraband (sugar) sells only at FRONTIER or rougher, at a premium. -- [ ] `/tw restart` when destitute: fresh kit, chart kept, hold emptied. -- [ ] Charity with no boat and no money: a Bamboo Raft. -- [ ] **Expanders:** sold only at Tech 7, install only into a Pale Oak Chest - Boat, price doubles each time. The nested panel refuses containers, the - TNT refuses a loaded expander, and in a smaller boat they ride inert but - their contents still sell. - ---- - -# Tier 5 — Crime (~25 min) - -- [ ] Entering island space triggers a customs scan at a rate matching the band - (SAFE often, ANARCHIC never). Clean scans announce themselves. -- [ ] Caught with contraband: the patrol launches from the pier and you can see - it coming — flee, fight, or destroy the evidence via the TNT slot. -- [ ] Escaping across the border flags you at that island; a chase that times - out does **not**. -- [ ] Reputation moves on crimes and decays with clean play; the band changes - your scan odds. -- [ ] Wanted: police respond, the bounty shows over your head, and killing a - wanted player pays out exactly once. -- [ ] Police drop nothing, ever, and never hurt bystanders. -- [ ] Fugitives are barred from safe-band markets. - ---- - -# Tier 6 — World, atmosphere, persistence (~20 min) - -- [ ] **Puffer shoal** (new 2026-08-06): loiter at sea in SAFE water - (~5-40 min at 0.5-2% per 45s roll — or bump encounters.chance.SAFE - temporarily): 2-3 pufferfish appear ahead with the "water prickles" - message. Swimming through stings and poisons; killing them drops at - most vanilla pufferfish, never booty. They never appear in FRONTIER - or rougher water. -- [ ] **Pirate crews ride their boat** (regression, 2026-08-05): in Lawless+ - water, when a PIRATE_CREW encounter spawns, the pillagers are IN the - boat and stay there while you keep your distance; they only jump out - when you close to ~10 blocks. Note whether they fire crossbows from - the deck - if they never do, raise `encounters.abandon-ship.PIRATE_CREW` - and treat the manned boat as arrival theatre. The witch still bails - out immediately (intended). -- [ ] Lighting a completed ruined-portal frame prints the "sea between - seas" refusal - no portal blocks form, in either world. -- [ ] **Interstice resources** (new 2026-08-05 — regenerate - `tradewinds_world_nether` first, placement knobs are needsReset). - Rig a warp failure (`/twadmin warpfail`) and look around the dark sea: - - [ ] Within a few hundred blocks: a soul-sand **wart shoal** with - nether wart growing on it; harvest and replant works; the rim is - wadable, not a cliff. Rarely, a shoal carries a crimson/warped - grove with shroomlights. - - [ ] Some braziers are **blaze pickets**: a 9x9 nether-brick deck on - legs, blazes spawning - and STAYING on the deck (regression - 2026-08-07: the old 3x3 nest dropped every blaze into the sea). - Fight from the boat, rods drop. - - [ ] **Glowstone clusters** hang from the ceiling lid, visible far - off across the water. - - [ ] **Quartz ore** in shoal cores and brazier roots, under the - waterline. - - [ ] A **wither watchtower** (they are ~1.5km apart - sail or rig - repeatedly): wither skeletons in the waterline room, doorway on - the north face, loot chest on the sealed floor above (bastion - treasure table - check it fills on first open), open top. - - [ ] **The ship graveyard** (v3, 2026-08-07): wrecks are DENSE (a - hull roughly every 130 blocks - regularly in sight) and ride - LOW - mostly submerged, with masts, sterncastles and keels - just breaking the surface (crest 6-8 under; at the first - tuning they stood proud like beached ships). A blue soul - flame burns on every hull's highest timber (mast-carried on - the fully sunken ones). Varied rotations, upside-down and - sideways hulls, no chunk-edge clipping. Most wrecks are - scenery with EMPTY chests; roughly one in three carries loot - - fortress-style, or on a lucky hull bastion finds (Pigstep, - trims) / a treasure wreck whose second chest holds bartering - goods (pearls, crying obsidian). Never a buried-treasure map. - - [ ] **The seafloor is scenery now** (2026-08-07): looking down - through the water shows glow lichen beds, basalt spikes, - magma vents, soul-sand seeps and boulders; the floor itself - rolls - shallow pale banks, dark basins, rifts, and blunt - seamounts that never break the surface. If it still reads - flat, raise interstice.seafloor-clutter. - - [ ] **Config-drift check** (the 2026-08-07 trap): after ANY jar - update that changes config defaults, verify the live - config.yml actually carries the new values - BentoBox keeps - existing keys, so new defaults never reach an existing - install on their own. - - [ ] **The dialog shows ONCE** (changed 2026-08-07): stranding pops - the re-engage dialog a single time; declining leaves only a - quiet action-bar reminder every prompt-seconds. `/tw go` and - `/tw warp` raise the offer again on demand. Leave and get - stranded again: the dialog pops once again. - - [ ] A rigged failure never strands you INSIDE a shoal or tower - - always open water. - - [ ] The free re-engage still works after any amount of lingering, - and ghast tears float where ghasts die. - - [ ] Nether wart and ghast tears occasionally drop as encounter booty - in the overworld. -- [x] Islet biomes suit their sea: snowy in frozen water, desert and mangrove in - warm. Cherry grove and pale garden exist somewhere. -- [x] Surfaces match the biome: desert sand over sandstone, badlands red sand - over terracotta, mangrove mud, old-growth taiga podzol, peaks bare rock. -- [ ] Roughly one islet in four carries a biome-appropriate structure (igloo, - fossils, ruined portal, abandoned camp). Mushroom and pale garden islets - never do. -- [x] **No jigsaw or structure blocks are visible** in any of them. A ruined - portal still stands on its netherrack footing; a camp tent has no - glowing blocks holding it up. (Find several - the camps and portals - 1/2/4/5 are the ones that carry connectors.) -- [x] Plaza amenities scale with tech: the galley everywhere, then furnace and - stonecutter, smithing and grindstone, brewing, anvil, and at Tech 7 an - enchanting table with bookshelves. All usable as a visitor, none - breakable. -- [x] Border curtains: blue at the edge of island space, red at the warp ring, - both passable. Colours follow `border.*`; nonsense values fall back rather - than vanishing. -- [x] A failed warp: the interstice is lit by braziers, lidded, the ghasts are - visible, and the free re-engage always works. -- [x] Restart mid-voyage: boats, cargo, fuel, ownership, chart and island stock - all survive. -- [x] Warp arrival never lands you inside terrain or on the quay. - ---- - -# Regression watchlist - -One-line re-checks for bugs already fixed once. Cheap to run, and every one of -these reached a playtest before. - -- [ ] Placing your own boat and boarding it does **not** offer to capture it. -- [ ] `/tw spawn` while riding: the boat comes with you and keeps its cargo. -- [ ] A warp arrival does not immediately re-open the warp dialog. -- [ ] The DOCK hologram floats above the waterline, never in the sea. -- [ ] Chart holograms do not vanish seconds after a second `/tw chart`. -- [ ] The expander opens by right-click **at sea** (aiming at nothing) as well - as ashore. -- [ ] A wrecked or dropped boat item never despawns on vanilla's 5-minute clock; - it runs its own TTL. -- [ ] Customs patrols actually move (anything past ~160 blocks never ticks). -- [ ] Trident hits and collisions yield the boat as an item, never destroy it. -- [ ] Villagers and golems do not walk into the plaza campfire. diff --git a/TRADEWINDS_SPEC.md b/TRADEWINDS_SPEC.md deleted file mode 100644 index db68eec..0000000 --- a/TRADEWINDS_SPEC.md +++ /dev/null @@ -1,218 +0,0 @@ -# TradeWinds — BentoBox Game Mode Specification - -**Name:** TradeWinds (decided — house style, one word, like BSkyBlock/AcidIsland; repo `TradeWinds` under BentoBoxWorld) -**Addon ID:** `TradeWinds` · **Package:** `world.bentobox.tradewinds` · **Commands:** `/tw` (admin: `/twadmin`) -**Target:** Paper 26.2 (Java 25 runtime, release-21 compile), BentoBox 3.18.1+ -**Author context:** Written for implementation via Claude Code by the BentoBox author. Assume full familiarity with GameModeAddon, WorldSettings, Blueprints, Flags, the BentoBox Database, and the AcidIsland/Poseidon codebases (closest architectural relatives). Companion docs: `tradewinds-overview.md` (pitch), `tradewinds-design-decisions.md` (rationale + open questions), `tradewinds-dev-plan.md` (stage plan), **`tradewinds-hold-plan.md` (normative detail for the virtual hold, One Boat rule, boat ranks, dropped-boat lifecycle and expanders — revised 2026-08-01)**. Where this spec and those docs disagree, this spec wins — except that the hold plan is authoritative on hold/boat mechanics. - ---- - -## 1. Concept - -A sea-trading game mode: an endless procedurally generated ocean dotted with **NPC trading islands**. Players start with a boat — the boat IS the cargo hold — and get rich buying low and selling high — honestly, or by smuggling, bounty hunting, and piracy. Inspired by TradeWars 2002 and Elite, rebuilt in Minecraft terms. - -| Elite/TW2002 ingredient | TradeWinds equivalent | -|---|---| -| Sectors / systems | Seeded sparse trading islands in open ocean | -| Hyperspace jump + fuel | Boat **warp** between island borders, paid in hold-carried fuel | -| Misjump / interdiction | **Interstice**: warp failure drops you in a hostile Nether sea | -| Cargo hold expansion | 20 boat ranks (Bamboo Raft → Pale Oak Chest Boat) → installed cargo expanders | -| Commodity market | BlueBook base prices × island type/biome/security modifiers | -| Police / legal status | Reputation bands, customs scans, police mobs, bounties | -| Player bases | Purchasable player islands in the open ocean | - -### Design principles (load-bearing — do not break) - -1. **The fuel/cargo tradeoff is the central mechanic.** Trading transacts **exclusively** against the player's **virtual hold** (server-authoritative, database-backed, sized by their one boat — see §4 and `tradewinds-hold-plan.md`) — never player inventory, ender chests, real containers, or anything else. Any feature letting sellable goods bypass the hold breaks the game's spine. Cargo leaves the hold only by being sold or destroyed. -2. **Risk symmetry between travel modes.** Warp: fuel cost, instant, interstice risk. Rowing: free, slow, lawless-ocean risk. Neither may become strictly dominant. -3. **Scans, not prices, balance contraband.** Sugar has infinite trivial supply; the customs-scan risk is the cost. Smuggling is a skill, not a tax. -4. **Crime pays you into danger.** Contraband sells only at less-safe islands; Fugitives are barred from safe-island markets. The richest criminals are structurally pushed into PvP space where bounty hunters lawfully operate. -5. **The seed is the world.** Island positions, existence, types, security bands, biomes, names, and route-graph edge costs are ALL pure deterministic functions of (seed, position) — unit-testable with no Bukkit dependency. Runtime randomness here breaks seed shareability. -6. **Generator-driven terrain, not pasted terrain.** Island landmass comes from the chunk generator (Poseidon-style noise × radial mask). Lazy generation is just chunk generation: no pop-in, no paste pacing. Blueprints/structures decorate; they never create the land. -7. **Scarcity by purchase-only endgame.** Cargo expanders are shop-only (top-tech ports), price-doubling per unit installed — the endgame money sink; the wallet is the cap. No End world, ever (nothing in the game needs it, and dimension exits break the ocean). - -## 2. World Model - -### 2.1 Worlds - -- **Overworld** `tradewinds_world`: endless ocean, AcidIsland/Poseidon-style generator (sea over noised ocean floor, **no acid**), islands from the placement engine (§2.2). Default sea height 70, sea floor ~25 (Poseidon numbers; config). -- **Interstice** `tradewinds_world_nether`: NETHER-environment world, styled as a hostile dark sea (lava-tinged sky, ocean of water — Nether environment gives the ambience; water block configurable). Same generator class, no islands. Registered as the gamemode's nether world with `netherIslands: false`; **no portals** — entry only via warp failure (Stage 5). Until then it must be inaccessible to players. -- **No End world, ever** (principle 7). `endGenerate: false` hardcoded default. - -### 2.2 Placement engine (pure, seeded) - -`GalaxyEngine` — plain Java, zero Bukkit imports, fully unit-tested: - -- **Input:** `galaxySeed` (long, config; shareable). All outputs are pure functions of (seed, cell/position). -- **Positions:** the ocean is divided into a coarse **spatial-hash grid** (cell size = `island-range × 2`, config). Each cell deterministically rolls (seeded by `hash(seed, cellX, cellZ)`) whether it hosts an island and where within the cell (jitter), then **rejection-samples against minimum separation** from neighbor cells' islands so protection zones never overlap and islands are never within sight of one another. Result: random-looking sparse scatter, deterministic, O(1) lookup of "which islands are near (x,z)". -- **Density**: base occupancy probability per cell, with a **density floor near spawn** (guaranteed starter cluster regardless of seed luck) and optional falloff outward. Proposed (adopt unless playtest says otherwise): density correlates with civilization — clusters are civilized, isolated islands anarchic; the map itself communicates risk. -- **Per-island attributes** (all from `hash(seed, islandPos, attributeSalt)`): - - **Type:** agricultural, fishing, industrial, mining, luxury, … (config-extensible list; drives market modifiers and blueprint set). - - **Security band:** EVE model — `SAFE`, `POLICED`, `FRONTIER`, `LAWLESS`, `ANARCHIC` (working names; config thresholds). Correlated with local density per above. - - **Biome:** whole-island, from a per-type weighted table. Frozen-ocean approaches are a feature (ice = fast boat lanes). - - **Name:** Elite-BBC-style procedural token-pair digraph generator, seeded — pronounceable, distinct. - - **Tech level (adopted 2026-08-01):** 1–7, seeded, correlated with type (industrial/luxury skew high, agricultural/fishing low, ±2 variance — "Advanced Agricultural" exists). Starter cluster guarantees at least one ≥ TL3 island. Shown in entry announcement and chart data: *"Esedaxe — Industrial (Tech 6), Safe."* Tech gates **shops only, never docking, riding or crafting**: boat ranks sold ≤ TL × 3 (TL7 = all twenty), expanders sold only at TL7. Tech also modulates prices (±`tech-price-step`, default 3%, per TL step from 4): high-tech sells **finished** goods (metals, food) cheap and buys **raw** (ores, crops, wood, fish, stone) dear; low-tech the inverse — best routes are tech *differentials*, read off the chart as type × tech × security. Higher tech also furnishes the plaza: every port has the galley (workbench + campfire); TL3+ adds furnace + stonecutter, TL4+ smithing table + grindstone, TL5+ brewing stand + cauldron, TL6+ anvil, TL7 an enchanting table ringed with bookshelves. - - **Range / protection radius:** AcidIsland framing — range ~1000, protection ~400 (config). -- **Reserved space:** cells that rolled empty are candidate sites for purchased player islands (Stage 7). - -### 2.3 Chunk generator - -`ChunkGeneratorWorld` owns the *shape* of the world; vanilla furnishes it. Vanilla noise and surface are off, so no vanilla continent can ever appear — but its carvers, decorators, mobs and structure placement all stay on. - -- **The sea floor** (`galaxy.Seabed`, pure and unit-tested — it is not scenery, its depth picks the ocean biome): a broad **basin** field taking the floor from sunlit shelf (~14 blocks down) to abyssal plain (~46), **relief** rolling dunes over it, ridged-noise **rifts** cutting narrow canyons up to 26 blocks deeper, and **seamounts** rising off the deeper plains only. Seamounts are capped below the surface: the galaxy's islands stay the world's only land (principle 6). Floor material follows depth in patches — sand banks, gravel beds, clay pans, bare stone and tuff in the deeps. -- **Island shelf:** near any island or islet the natural floor eases to a standard shelf depth, so an island that falls over an abyssal plain still stands in shallow water and clears the waves by the same amount. This also makes each island's shoreline radius exact rather than noise-dependent. -- Near an island center (from `GalaxyEngine` — O(1) neighbor-cell lookup per chunk): the shelf is lifted by a **radial falloff mask** `m(d)` (1 at center → 0 at the island's terrain radius). The distance `d` fed to that mask is **warped by seeded noise** (`galaxy.coast-roughness`) so the mask draws bays and headlands rather than a disc, and the lift is modulated by a second field (`galaxy.island-hilliness`) so the land is hills and hollows rather than a dome. Everything that tests the island footprint — biome, shelf, icy approach ring — must use the same warped distance, or it draws a circle over ragged terrain. -- **Shores** are read from the finished terrain (land within a few blocks of sea level), never from a radius, so a beach follows the real waterline whatever shape the coast is. -- **Biome:** `BiomeProvider` returns the island's biome within its terrain radius (per column); islets return their own biome with a sandy **beach ring** at the waterline; open sea returns an ocean biome chosen by seeded temperature *and depth* — deep water gets the `deep_*` variants. That depth-to-biome mapping is what tells vanilla where **ocean monuments** belong; temperature is what separates warm from cold **ocean ruins**. -- **Vanilla structures** (`world.make-structures`) supply shipwrecks, ocean ruins, monuments, buried treasure and trial chambers. They are suppressed per-chunk over trading islands (`world.keep-structures-off-islands`) so nothing drops through a hand-built plaza or dock; wild islets are fair game. **Vanilla caves** (`world.make-caves`) carve under the sea floor and inside the islands; `generateCaves` then seals a crust back over anything they cut through the floor, so the sea floor is never left open (carvers know nothing about the ocean above, and generated chunks get no block updates to flood the holes). The caves survive under it. -- Interstice variant: NETHER environment, its own shallow drab sea floor over basalt/soul sand, no galaxy, no structures. -- **Island registration:** on first generation of an island's center chunk (or first entry — listener on chunk load), register an **unowned BentoBox island** at the center with range/protection from config and flags per security band. Island name announced on entry (BentoBox island enter event → action bar/title). - -## 3. Travel - -### 3.1 Rowing - -Free. The border (Border addon in passable/visual mode, or protection-range visuals) does not block movement. Open ocean between islands is lawless: no protection flags apply (PvP live), no police. Ice lanes are fast. - -### 3.2 Warp - -- **Trigger:** player in a boat reaches an island's border (proximity trigger + action-bar prompt; packet-level "click the wall" rejected as needless complexity). Opens the **warp dialog** (Paper dialog API): charted islands listed with per-destination fuel cost; unaffordable/unreachable grayed out. -- **Route graph:** seeded per-edge costs; default = Euclidean distance × `fuel-per-block` multiplier; per-edge overrides in config allow cheap "warp lanes" and expensive frontiers without world regen. -- **Fuel:** value table (config): wood < coal/charcoal < coal block < lava bucket (non-stackable = deliberate tension). Consumed **from the hold's fuel slots only** (principle 1; 7 dedicated slots, fuel never competes with cargo and moves freely both ways). Empty buckets returned. -- **Execution** (AcidIsland `/ai` proven pattern): dismount → teleport player + boat cross-world-safe → re-seat. Arrival just inside destination border **on the bearing of the origin island**. Effects: nausea + blindness (durations config) + configurable minor damage ("warping hurts" — gates the under-equipped), portal particles + sound. -- **Charting:** dialog lists **charted** islands only. Chart by physically entering an island's range (free, slow); starter cluster pre-charted for new players. Buying chart data = post-MVP. Charting is the discovery layer over lazy generation. - -### 3.3 Interstice (warp failure) - -- Config failure chance (base; wanted players may get a higher rate — adopted from proposals). On failure: player + boat placed on interstice water **partway along the route** (seeded fraction ± jitter), 1–3 Ghasts spawned inbound. -- **Re-engaging the warp to the original destination is always free** — fuel was spent at engagement; failure is a detour, never a loss. This makes stranding impossible. Do not remove. -- Ghast-vs-boat combat intentionally survivable (fireballs battable). Interstice is a **shared** world (interdiction/ambush meta — very Elite); PvP rules there are an open question (§10). -- Stale entity cleanup: interstice mobs despawn on player exit / world empty. - -## 4. Cargo & Progression (canonical, revised 2026-08-01 — full detail in `tradewinds-hold-plan.md`) - -- **One Boat Rule:** a player has exactly one boat, or none. The hold is the *player's* virtual cargo inventory; the boat they possess only sets its size. Upgrades never move items — the slot count grows, contents stay put. -- **Virtual hold:** server-authoritative, database-backed (`PlayerHold`); **no real container ever holds cargo**. Slot-stack model: up to 21 cargo slots (by boat rank) + 7 fuel slots, auto-consolidated. GUI via BentoBox Panel API: TNT destroy slot, locked-slot panes, fuel row. Opened by right-click while riding, sneak-right-click the boat entity, or right-click the boat item. -- **One-way cargo:** anything can go in (except container items — bundles, shulkers, pouches); cargo leaves only by being **sold** or **destroyed** (TNT slot). Fuel is exempt: freely added and removed, never destroyable. -- **Boat ranks:** 20, Bamboo Raft (2 slots) → Pale Oak Chest Boat (21 slots); config map `boat-material → slots`. Prices quadratic `25 × slots²`. Shops list only bigger boats, gated by island tech (rank ≤ TL × 3); **a purchase replaces and destroys the old boat** (a trade-in at the yard); **a bigger-boat pickup is a swap**: your old boat becomes the floating item, carrying whatever overflow did not fit - nothing is ever lost to the sea. Crafting bypasses tech gates; crafting smaller than current is blocked; adding a chest to the current boat is an in-place upgrade. -- **Start kit:** Oak Boat (rank 2, 3 slots) + starter coal in the fuel slots. Charity/destitution grants a Bamboo Raft. `/tw restart` resets to the start kit. -- **Dropped boats:** a boat item (thrown, death-dropped, or broken loose) carries its hold via a DB record keyed by a PDC UUID, under a DB-persisted TTL. Pickup: bigger = swap (you keep your cargo, salvage merges in, overflow floats on in your old hull); smaller/equal = most-valuable-first transfer into free space, remainder stays claimable; no boat = it becomes yours. Handing a loaded boat to another player this way is sanctioned trade, not an exploit. Boats are destroyed only by lava; death drops the boat with contents (keepInventory keeps it; DeathChest chests it). -- **Cargo expanders:** install only in a Pale Oak Chest Boat, occupy one cargo slot each, open a nested 21-slot panel (net +20; no fuel row, no containers, no nested expanders; TNT-destroyable only when empty). Price `5000 × 2^installed`, no cap — the wallet is the cap. Sold only at TL7 ports. A fully expanded ship approaches ~440 slots: the guard-it-with-your-life endgame. -- **Boat ownership:** owner UUID in boat entity PDC from day one (theft/persistence/bounty questions hang off it). - -## 5. Economy - -### 5.0 The two economies (adopted 2026-07-30; stamping removed 2026-08-01) - -TradeWinds runs **two parallel economies**, bridged by the hold: - -- **The trade economy (money).** Customs stamping is **removed entirely**. - Traders buy anything the hold carries, at prices set by island type, tech, - band and the per-island **stock/demand pools** — capacity (a hold slot is - scarce) and pool drift (selling into an island crushes its price toward the - drift floor; pools decay back slowly) are what carry the balance that the - stamp used to. Money still overwhelmingly enters through trade margins, - because hauling bought goods up a price gradient beats farming into a - saturating pool. -- **The vanilla survival economy (stuff).** **Wild islets** (small unnamed - islands on their own fine grid; `galaxy.wild-islet-*`) are free country: - mine, farm, build, sleep. Each rolls its own size and biome, so they range - from sandbars to proper little islands, with a sandy shore and rarely - (`galaxy.mushroom-islet-chance`) mushroom fields. They must be **common - enough to meet by chance while sailing** — an empty sea is the failure mode. - The sea between them carries the vanilla furniture too: shipwrecks, ocean - ruins, monuments in the deep basins, buried treasure on the beaches, trial - chambers in the rock, and caves under the floor. All protection flags default - to allowed outside named islands' protection ranges. Homemade goods are - usable directly (eat, wear, build, burn as warp fuel) and, via the hold, - sellable into the pools. -- **Contraband** remains its own config list (`illegal-trade.contraband-materials`, - default sugar) with the ×premium black-market price at FRONTIER-or-rougher - ports, balanced by scan risk per principle 3 — no longer coupled to any - stamping concept. - -Survival needs are met by the **outfitter** shelf every island guarantees -(bread always, charcoal when the trade catalog carries no fuel, gear by type: -smiths at INDUSTRIAL, beds at farms, rods at fisheries) and by wild-islet -living. Destitution has an exit: `/tw restart` (config-capped) resets balance -and kit, keeping the chart. Wild islets are the future claim targets for -Stage 7 player islands (teams + Bank addon). - -- **BlueBook** (`~/git/bluebook`, addon name `BlueBook`) supplies base prices via `PriceEngine.getPrice(ItemStack, worldName)` (single price; TradeWinds derives buy/sell spread). Soft-depend; fail gracefully with a config fallback table if absent. -- **Per-island modifiers:** island type (farm sells food cheap, wants raw materials…), biome, security band (margins scale with danger), plus per-island **stock and price drift** persisted via the BentoBox Database (keyed by island). Everything in Minecraft is tradeable; players may sell found/crafted goods into the same market. -- **Trade GUI** at the dock (villager interaction or dock sign), transacting only against the hold (§4). -- **Vault** economy for balances; starter balance config. - -## 6. Contraband & Customs - -- **Master config gate** `illegal-trade.enabled` disables ALL illegal-goods mechanics (family servers). Default on. -- **Contraband:** sugar (config list; never called drugs anywhere — code, config, locale). **Villager "passengers"** in boats sellable at less-safe islands only (boating the villager is the player's problem — emergent). Gated separately (`illegal-trade.passenger-trade`). -- **Customs scan on every entry** into island space — rowed or warped. Chance per security band (SAFE scans often; ANARCHIC never). Per-island scan cooldown prevents re-entry scumming. -- **Detection = chase, not fine:** "customs patrol dispatched" — police launch from the island; ~400 blocks of water is the decision window: **flee** across the border, **fight**, or **jettison — destroy-only** (ruled 2026-08-01): dump the evidence into the hold GUI's TNT slot and it is gone, a last-ditch option when you cannot beat the police or escape. Nothing floats. **Caught** = police land a hit or close to proximity radius while contraband aboard → confiscation + fine + rep loss. -- **Fleeing a detection flags you** at that island for a cooldown: re-entry skips the roll, police launch immediately. - -## 7. Reputation, Police, Bounties - -- **Single global score**, named bands: `UPSTANDING / CLEAN / OFFENDER / WANTED / FUGITIVE` (working). Proposed numbers (adopt as defaults, all config): scale −1000..+1000; hit villager −5, kill villager −25, kill police −15, kill innocent −100, scan-caught −30 + fine, passenger sale −20; decay +1 per 15 min clean active play toward zero; thresholds +250 / 0 / −200 / −500. -- **Recovery, three speeds:** slow clean-play decay (crimes shadow you for sessions); fines (capped — money buys you out of Wanted, not into virtue); positive acts (contracts/donations — post-MVP) above zero. Positive rep must pay: better prices, lower scan odds — Upstanding is a pursued status. -- **Wanted:** PvP damage override anywhere; growing bounty; police response at civilized islands. **Fugitive:** shoot-on-sight, barred from safe-island trading (principle 4). Killing a wanted player pays the bounty, no rep penalty — bounty hunting is lawful work. Bounty pays exactly once. -- **Bounty nameplate** (config-gated, default on): bounty displayed with the player's name via per-player scoreboard team suffix, only when bounty > 0. PlaceholderAPI placeholder exposed (`%tradewinds_bounty%` etc.) so TAB-style plugins can render instead — avoids conflicts. (Boats prevent sneaking: traveling pirates are always visible. Feature.) -- **Police by mobility:** **Phantoms pursue** (combustion cancelled or day patrols burn at dawn), **Guardians hold the sea perimeter** (area denial, never chase boats), **Iron Golems** take anyone who lands. All PDC-tagged, ticking `setTarget` reassertion task, **zero drops** (else wanted players become a crime-powered iron farm). Pursue within protection zone; break off when target crosses the visible border; despawn beyond island range or on chunk unload. -- **Per-band knobs:** scan chance AND response size — a mid band may scan rarely but respond hard. Route planning with contraband is map-reading skill. -- **Anti-bait guard:** innocent-kill penalty on direct kills only; prior damage from the victim exempts. Log edge cases; don't over-engineer pre-MVP. -- **Villager gossip** API hooked for organic price penalties at the offended island. - -## 8. Player Islands (Stage 7) - -Purchase command: sufficient balance → choose blueprint → placement at a valid reserved position (empty grid cell) respecting minimum separation from **all** islands (NPC and player). Standard BentoBox island/team management, protection, homes thereafter. AcidIsland-equivalent island life. - -## 9. Data Model (BentoBox Database objects) - -- `TWPlayerData` (by player UUID): reputation score, bounty, chart set (known island IDs), flags (per-island flee-flags with expiry), scan cooldowns. -- `PlayerHold` (by player UUID): boat material (or none), cargo contents `[{material, amount}]`, fuel `[{material, amount}]`, installed expanders with nested contents. -- `DroppedBoat` (by hold UUID, mirrored in the boat item's PDC): boat material, contents, fuel, `expiresAt` TTL. Ownership transitions (drop → pickup → replace) are atomic moves between `PlayerHold` and `DroppedBoat`; duplicating a key item cannot duplicate cargo. -- `TWIslandData` (by island ID): galaxy cell coords, type, security band, biome, name, stock levels + price drift map, market state timestamps. -- `TWWorldData` (singleton): galaxy seed (authoritative copy; config seed used at first creation then persisted), route-edge overrides cache, bounty ledger totals. -- Boat ownership, police tags, loot tags: entity **PDC**, not database. -- Transient (in-memory): active chases, dialog sessions, pursuit tasks. - -## 10. Open Questions (running list — resolve before/at the flagged stage) - -1. **Poseidon mask internals** (Stage 1): mask multiplies noise amplitude vs. lifts a base height — prototype both, pick by coastline quality. -2. **Dock placement** (Stage 2): deterministic terraformed shelf at fixed bearing (lean) vs. jigsaw adaptive. -3. **Boat loss rules — RESOLVED 2026-08-01** (`tradewinds-hold-plan.md`): boats always drop as an item with hold contents attached (DB-keyed, TTL) — death, trident, collision alike; only lava truly destroys. Insurance remains post-MVP. -4. **Interstice PvP** (Stage 5): is interdicting non-wanted players lawful or itself a rep crime? -5. **Scan/flag tuning** (Stage 6b): cooldown lengths, caught-proximity radius. -6. **Galaxies architecture** (post-MVP): multi-instance addon vs. world manager. Keep world references abstracted from day one. -7. **Villager shoreline safety** (Stage 2): keep villagers inland behind docks so drive-by raids don't trivialize police. -8. **Reputation numbers** (Stage 6a): defaults above are proposals; playtest-tune. - -## 11. BentoBox Integration Checklist - -- `TradeWinds extends GameModeAddon`; `addon.yml` api-version 3.18.0, icon `OAK_CHEST_BOAT`, softdepend `BlueBook, Border, Level, Challenges, Visit, Warps`. -- `Settings implements WorldSettings`, `@StoreAt(filename="config.yml", path="addons/TradeWinds")`, every gameplay number a `@ConfigEntry` from the stage it appears. Master gates: `illegal-trade.enabled`, bounty nameplate, interstice. -- Commands: `/tw` (`DefaultPlayerCommand`: go/spawn, status, chart, sell/buy arrive with their stages), `/twadmin` (`DefaultAdminCommand`: seed info, island info, regen checks, rep set, force-scan…). -- Flags: security-band flag presets on NPC islands; new protection/world flags as systems land (e.g. `TW_MARKET`, `TW_CUSTOMS_EXEMPT`) — created per stage, not speculatively. -- Custom events for the ecosystem: `IslandChartedEvent`, `WarpEvent`/`WarpFailedEvent`, `CustomsScanEvent`, `SmugglingCaughtEvent`, `ReputationChangeEvent`, `BountyPaidEvent`, `TradeEvent`. -- Placeholders: reputation band/score, bounty, charted count, current island name/band, hold value. -- Locales: `locales/en-US.yml` reference-style; all player-facing text localized. -- Level addon: not meaningful for NPC islands; player islands (Stage 7) work unchanged. - -## 12. Milestones (= dev-plan stages; each ends green-build, playable, TESTING.md updated) - -- **Stage 0 — Scaffold:** GameModeAddon; overworld + interstice worlds (ocean generator, no acid); `/tw`/`/twadmin`; config skeleton; no End. *Accept:* loads alongside other gamemodes; `/tw` teleports to ocean spawn; interstice exists, players can't reach it. -- **Stage 1 — Seeded galaxy:** GalaxyEngine (positions/types/bands/biomes/names, pure + tested); generator radial mask; lazy island registration; name announce. *Accept:* same seed → identical galaxy across runs (unit-proved); rowing out reveals biomed islands, no pop-in; islands persist in DB. -- **Stage 2 — Island content:** deterministic dock shelf; blueprint sets per type/biome; jigsaw decoration (Boxed pattern); villagers + resident golems. -- **Stage 3 — Travel:** border proximity prompt + warp dialog; route graph; fuel table + hold consumption; warp execution with re-seat; charting. -- **Stage 4 — Economy & cargo:** BlueBook + modifiers + persisted drift; dock trade GUI (hold-only); bundle/chest-boat/expander progression; Vault. -- **Stage 5 — Interstice:** failure roll, mid-route placement, Ghasts, free re-engage, cleanup. -- **Stage 6 — Crime (3 sessions):** 6a reputation core; 6b customs & contraband; 6c police & bounties (+ nameplate, PlaceholderAPI). -- **Stage 7 — Player islands:** purchase + placement in reserved cells; standard island life. -- **Stage 8 — Post-MVP backlog:** stalls, chart data sales, intel, insurance, contracts, galaxies, Elder Guardian tier. - -## 13. Environment & Verify-First - -- **Toolchain (verified via Gusher — do not "upgrade" blindly):** Paper API `26.2.build.40-alpha` (Java 25 bytecode → build with JDK 25, compile release 21); BentoBox `3.18.1` provided (server runs 3.21.1-SNAPSHOT); MockBukkit has **no 26.2 release** — use locally patched `org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:4.113.4-p262` (GushBlock's `scripts/build_patched_mockbukkit.py`) + the two Adventure shims + surefire `--add-opens` set. `org.bukkit.Sound` is an **interface** (no `valueOf`); config-supplied sounds resolve via `RegistryAccess`. -- **Verify at Stage 3:** Paper 26.2 dialog API surface (design docs reference the 1.21.6 API; confirm current package/builders). -- **Verify at Stage 0/1:** BentoBox unowned-island registration path (`IslandsManager.createIsland` without owner) and per-world portal suppression for the interstice. -- Test server: `/Users/ben/Minecraft/26.2` (jar → `plugins/BentoBox/addons/`). Manual checklists live in `TESTING.md`. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md deleted file mode 100644 index a98caec..0000000 --- a/docs/PROGRESS.md +++ /dev/null @@ -1,2821 +0,0 @@ -# TradeWinds — Progress Log - -What is done, and pitfalls hit on the way. Newest stage first. Read -`TRADEWINDS_SPEC.md` for requirements; this file records reality. - -## Interstice playtest: the once-only dialog, invisible wrecks, drowned blazes (2026-08-07) - -Three rulings from the first interstice-resources playtest: - -**The re-engage dialog shows ONCE per stranding** (ruled by Ben: the -repeats were irritating). It used to re-open every prompt-seconds - fine -while the interstice was empty water, unbearable once it had wart, blazes -and wrecks worth staying for. Now: dialog once on arrival; after that a -quiet action-bar line every `prompt-seconds` (0 silences it), and -**`/tw go` or `/tw warp` raises the offer again on demand** - both -previously gave useless refusals in the nether world ("already at sea" / -"wrong world"). Leaving the interstice re-arms the one-time dialog; -`IntersticeService.prompted` tracks it, `tick()` retains only players -still in the world. - -**The wrecks were generating all along - they were just invisible AND far -too rare.** Region-file scans found exactly one wreck per regen in the -flown area (a hull spanning chunk borders - so Structure.place into -LimitedRegion works on the live server), on the deep seabed of a dark sea -where it reads as empty water. Ruled by Ben: the point is a VISIBLE, -DENSE ship graveyard - hulls half in, half out of the water, and they do -not all need loot. Graveyard v2: - -- **Reef mounds in the terrain.** `IntersticeMap.wreckSurfaceAt` raises - the seabed under every wreck (dome, radius 18, crest 2-4 blocks under - the surface - the seeded `sink` now varies the crest, so hulls ride - high and dry, awash, or just under: the undulation). The generator - maxes it into the nether floor exactly like shoal domes; the decorator - perches the keel ON the crest instead of burying it in the deep. -- **Density up 10x**: grid 320→96, chance 0.4→0.5 - a hull roughly every - 140 blocks, always one in sight from a boat. -- **Loot rationed**: `interstice.wreck-loot-chance` 0.3 - only that - fraction of wrecks carry stocked chests (grades as before); the REST - ARE SCENERY, chests explicitly emptied (a template chest left alone - rolls vanilla's shipwreck tables, buried-treasure map included). -- **Grave-lights v2** (`interstice.wreck-flames`): the soul flame now - burns on the hull's highest timber; only fully sunken hulls get the - basalt mast to carry it above the surface. At night the graveyard is a - field of cold blue lights. -- Wreck reefs are excluded from `isOpenWater`, so strandings never drop - a sailor into a hull. -- **Graveyard tuning after Ben found them (2026-08-07)**: crest depth - 2→6 blocks under (hulls now ride mostly submerged, top works just - breaking the surface - at 2 they stood proud like beached ships); - density grid 96→64 at 0.5 (a hull every ~90 blocks; "make it denser to - see how much feels right" - back it off in config if it is too much). - The live server config had kept the OLD sparse values across the jar - update - BentoBox preserves existing config keys, so changed DEFAULTS - never reach an existing install; the coordinates handed over were - computed for the new grid and the world was running the old one. - Updated the live config by hand (server stopped). Remember this trap - for every future default change. -- **Dunes (2026-08-07, after "I don't see the sea floor undulating")**: the - Seabed's finest wavelength was RELIEF_LATTICE 220 - +/-10 blocks spread - over 200-block swells is 1-3 blocks across one glance, i.e. visually - flat. New short dune field in `Seabed` (SeabedConfig gains `duneHeight`, - DUNE_LATTICE 32): the interstice runs dunes 4, and the 7-arg - SeabedConfig constructor keeps dunes 0 so every pre-dune world's floor - is byte-identical under its generated chunks (SeabedTest pins both). -- **The interstice seafloor got scenery (2026-08-07)**: seabed profile - deepened and roughed up (shelf 6/abyss 34/relief 10/rifts 16@0.82/ - seamounts 12 - was 10/26/relief 6/no seamounts, flat by design from - when the interstice was a dead end); and a seafloor-clutter pass in the - decorator (interstice.seafloor-clutter, 0.03/column) scatters glow - lichen beds, basalt spikes, magma vents, soul-sand seeps and blackstone - boulders on the bottom. None of it is a resource - it is what makes - looking down through the water worth doing. -- **The interstice map now seeds off the GALAXY seed**, not the interstice - world's own (random-per-creation) seed. Found while trying to hand Ben a - wreck coordinate: with a random world seed, every nether regen shuffled - every feature and no coordinate could be predicted - and two servers - sharing a galaxy seed got different interstices, against spec principle - 5. Now `galaxy.seed` decides the interstice too (world seed only as the - seed-0 fallback), regens are reproducible, and features are computable - offline: with the default seed 20260729, wrecks lie at (32,-40), - (29,55), (-42,68), (-122,164 - loot), ... verified by running - `IntersticeMap` in jshell against a Python port of `Hashing`. - -**Blazes drowned on spawn.** Spawners place mobs up to 4 blocks out -horizontally; the picket's 3x3 crow's nest dumped nearly every blaze into -the sea, where it died on contact. The picket is now a 9x9 nether-brick -deck (DECK_HALF = spawner spawn radius) with a wall ring and corner legs - -every spawn lands on dry deck. Both decorator fixes need a -`tradewinds_world_nether` regen. - -## The ship graveyard - wrecks in the interstice (2026-08-06) - -Ben's idea: the interstice should feel like a ship graveyard. Built the same -day. `IntersticeMap.Wreck` (pure, seeded: grid 320 / chance 0.4, variant, -quarter-turn rotation, 0-2 blocks of burial all from the cell hash) and a -`wrecks()` pass in `IntersticeDecorator` that places the 20 vanilla shipwreck -templates through `Bukkit.getStructureManager().loadStructure` into the -`LimitedRegion` - the `RegionAccessor` overload of `Structure.place`, with -the origin computed per rotation so the hull is CENTERED on the wreck point -(that is what keeps a 28-block template inside chunk+buffer; the rotation -pivots on the origin block, so each rotation wants a different corner). - -Loot mirrors the vanilla nether, by wreck grade (ruled by Ben): COMMON 70% → -fortress chest table, RARE 25% → bastion_other (Pigstep, trims, netherite -upgrade template chance), TREASURE 5% → bastion_treasure plus a captain's -locker chest rolling piglin bartering (pearls, crying obsidian, soul speed). -Every template chest is re-pointed, which also removes vanilla's -buried-treasure maps - nothing in this ocean for them to point at. Pearls -in locker quantities slightly relax the "End goods trade-only" ruling - -recorded in the interstice plan. - -Config: `interstice.wreck-grid/-chance` (needsReset), `wreck-templates` -(any loadable structure key works - servers can add custom wrecks), -`wreck-loot` map (must stay complete in config.yml, as ever). Needs a -`tradewinds_world_nether` regen to appear. NOT yet live-verified: the -`Structure.place`-into-`LimitedRegion` path is API-correct but has not run -on the real server - first playtest should confirm wrecks appear at all -before judging their looks (TESTING.md Tier 6). - -Playtest: Ben died (phantom, 28s after login, 4,400 blocks from home) -carrying his spruce boat; two real days later the chart still pointed the -OLD BOAT marker at his own dock, where nothing was. Region-file forensics -(the server logs said nothing but the vanilla death line) found the whole -death drop intact in an unloaded chunk - item despawn clocks only tick -while chunks are loaded, so the kit was frozen at Age 218/6000 - including -TWO spruce boat items stamped with the same boat-id. Four causes, four -fixes, all guarded by `BoatArchaeologyTest`: - -- **Death drops now move the record to the wreck site.** Manual drops - always did (`onDrop`); death drops bypass `PlayerDropItemEvent`, so the - chart lied forever. New `onDeath` handler re-remembers every stamped hull - in the drops at the death location. Deliberately NO TTL: a wall-clock - expiry would delete the cargo record while the item sat recoverable in an - unloaded chunk. (The chart marker vanishing when Ben got close was - `MARKER_MIN_DISTANCE` working as designed - it points at the record's - position and suppresses within 32 blocks; the position was simply stale.) -- **Creative placement duplicated the hull item** - vanilla does not - consume the placed item in creative, so place-then-break minted a second - stamped copy (the 2026-08-02 two-hulls exploit, reborn). `onPlace` now - strips carried copies of the id after a creative place, and `onPickup` - dissolves a ground item whose id you already carry. -- **The always-sells purchase left a carried old hull in the pack.** - `takeBoat` sheds it; the 2026-08-05 outright-replacement branch did not. - Both now use `BoatService.shedCarriedHull` (removes EVERY copy - dedupe - - drops one stamped hull at the feet, re-remembers position), with the - purchase path using the same swap-quiet as capture so the shed hull is - not offered straight back. -- **Encounter boats minted database records.** The pirates' oak boat, - boarded or broken after the fight, self-registered as an unowned hull - (`recordFor(entity)` registers anything) - one litter record per pirate - attack, forever. Encounter-tagged boats are now scenery: boarding never - captures, breaking splinters them to nothing (a free hull per encounter - win would be farmable). The one orphan record on the test server was - moved to `database/.trash`. - -**The boat logbook** (`boats.logbook`, default ON): one console INFO line -per lifecycle transition - placed, broken up, picked up, claimed, demoted, -refit, shed, handed over, burned, went down with its sailor, restored - -with material, id, owner and coordinates. **`/twadmin boat -[restore]`** reads the records straight from the database (cargo, fuel, -last-seen, whether an avatar is loaded there or in someone's pack) and -regenerates a lost active boat as a stamped item in the player's pack, -refusing while a loaded avatar exists (restoring next to a live hull mints -a duplicate). Together they make the next "where did my boat go?" -answerable without region-file forensics. - -## The puffer shoal - SAFE waters get their spice (2026-08-06) - -The SAFE band's 2% encounter chance was decorative: every encounter needed -POLICED+, so the roll fired into an empty table. Ruled by Ben: SAFE needs a -nuisance - interesting, not farmable. Now: **PUFFER_SHOAL**, 2-3 pufferfish -across your course, any time of day, SAFE and POLICED bands only. - -Two new properties on `EncounterType` carry the design: -- **`nuisance`** - a hazard, not a hunter: exempt from the - every-mob-must-be-an-Enemy test (the pufferfish stings on contact without - ever being an Enemy), and worth NO booty - (`EncounterListener` checks the tag's type), so there is nothing to farm - - the only "loot" is the vanilla pufferfish, which fishing gets you faster. -- **`maxBand`** - encounters can now cap out: the shoal stays out of - FRONTIER+ where it would only dilute real danger (a uniform pick means - every filler entry steals share from pirates). - -The encounter PDC tag recording the TYPE (from the abandon-ship fix) is what -made the booty exemption a one-liner. - -## Island members respawned at spawn — our own listener stomped BentoBox (2026-08-05) - -Playtest: died owning a claimed islet, ISLAND_RESPAWN active, respawned at -the spawn port. Cause: `SpawnRespawnListener` (respawn SAFETY, written -before player islands existed, to keep bedless respawns out of the seabed) -runs at HIGH and unconditionally set the spawn plaza - AFTER BentoBox's -ISLAND_RESPAWN listener had set the island home at NORMAL. Fix: island -members now pass through untouched; the listener's job is only the -islandless. The loaner boat still applies on any respawn - the real boat is -wherever they died. Regression test pins the pass-through. - -Same-session playtest note, NOT a bug: "cannot buy a boat at Spawn" while -owning a Spruce Boat is the tech gate working - spawn is TL1 and sells -ranks 1-3 only, and the shipwright is upgrades-only by design. The recovery -path after a far-away death is the respawn loaner raft, then any TL2+ port. -Boarding the loaner (abandoning the distant boat, standard confirmation) -resets "what you sail" to the raft - and THEN the TL1 yard will happily -sell up to the oak. The rules the playtest wanted already existed; the -loaner just never arrived: - -**The loaner raft never arrived - items granted during PlayerRespawnEvent -are wiped** when the respawn restores the inventory. The grant now runs one -tick AFTER the respawn, on the player's real post-respawn position (which -also makes boat-within-reach honest for island respawners). And the -shipwright's "nothing on the slipway beats your boat" notice moved from a -truncating fake BUTTON into the dialog body, with the one offered button -pointing somewhere useful: the outfitter. - -**Rule change - the yard ALWAYS sells (ruled by Ben, 2026-08-05).** The -stranding above exposed the corner: dead at spawn, spruce boat an ocean -away, TL1 sells nothing "bigger than you sail" - a rich sailor with no way -off the island. New rule: shops list every hull the tech can build except -the size you sail. A bigger hull with your ship AT THE QUAY is still the -trade-in (same record, cargo stays, old hull broken up); anything else - -ship elsewhere, or a smaller hull - is bought OUTRIGHT: new boat in hand, -old boat left unowned where it lies with its cargo, first come first -served. The purchase gets the same confirmation dialog a boat capture does, -because it is the same decision. `refit-needs-ship` is gone; the abandoned -hull's plate flips to UNOWNED via the identity relabel. - -## Interstice resources — the nether plan built (2026-08-05) - -`tradewinds-interstice-plan.md` N1+N2+N3 are IN, one session after drafting. -The interstice is now a place worth lingering in when fate drops you there - -entry stays by-accident, the free re-engage stays free, and everything -gathered already had a price. - -- **`galaxy/IntersticeMap`** - pure seeded geometry (no Bukkit, headless - tests): wart shoal cells, grand-shoal rolls, watchtower cells, the shoal - dome function, and `isOpenWater` for stranding safety. Salted off the same - seed as the interstice sea floor. -- **Generator**: shoals rise through `floorTopAt` (max of natural floor and - the dome - crown 2 proud of the sea, rim submerged), and interstice LAND - columns are soul sand through the crown, because wart plants only on soul - sand. -- **Decorator** (`IntersticeDecorator`, grown from the brazier populator): - wart crops (age-randomised) on shoal tops; crimson/warped groves on grand - shoals (hand-rolled stem+cap+shroomlight trees); glowstone blobs hanging - from the ceiling lid; quartz ore in shoal cores and brazier roots; blaze - pickets (nether-brick crow's nest + BLAZE spawner) on a config fraction of - braziers; and wither watchtowers - 5x5 nether-brick towers, WITHER_SKELETON - spawner room at the waterline with a doorway and a soul-sand pile, sealed - loot floor above it with a chest on the vanilla - `minecraft:chests/bastion_treasure` table (config key), crenellated open - top. Netherite stays loot-only, exactly per plan. -- **Stranding safety**: the interstice has LAND now, so - `SeaArrival.openSeaNear` gained a column-test variant and `strand()` feeds - it `IntersticeMap::isOpenWater` - a failed warp can never materialise a - sailor inside a shoal or a tower wall (the suffocation bug's third - cousin, pre-empted this time). -- **Booty**: NETHER_WART and GHAST_TEAR joined the encounter booty table - - the trickle for sailors who never fail a warp. -- All knobs in `config.yml` under `interstice.*`; the placement ones are - `needsReset` - **regenerate `tradewinds_world_nether`** to see features. -- Deferred from the plan: magma cubes (texture only; fire resistance is - craftable), secondhand-shelf seeding, price spot-checks (N3 notes). - -Implementation facts worth keeping: `LimitedRegion` HAS -`getBlockState(int,int,int)` - spawner/chest tile config in a populator is -setType, getBlockState, mutate, update(). Loot tables resolve through -`Bukkit.getLootTable(NamespacedKey)` and a bad config key logs and leaves a -plain chest. - -**Same session, the orphan batch button.** Playtest screenshot: the -outfitter showed a bare "x16 - $7,184" no one could attribute. It was the -COMPASS batch (449 x 16) - unstackable gear (the fishing rod) gets no batch -button, which shifts the dialog's two-column pairing, and `outfit-batch` -labels carried no item name, so the orphan sold a mystery. Fix: the batch -label now names its good ("Compass x16 - $7,184"). Fourth member of the -artifact-drift family in spirit: a layout assumption (pairs) and a label -assumption (context from the neighbour) that individually were fine and -together broke the moment the shelf gained one unstackable item. - -## Pirates abandon ship on their first tick, and the interstice plan (2026-08-05) - -**The pirate crew never rode its boat.** Playtest: pillagers "appear outside -the boat and paddle toward me". They WERE seated - then ejected within a -second, because `hunt()`'s abandon-ship radius (flat 30) was WIDER than the -encounter spawn distance (28): every crew spawned already inside it and went -over the side on the first tick, and the boat drifted away empty. The wide -abandon exists for the SEA_WITCH (potions overshoot from a drifting -platform); pirates got swept up in it. Fix: abandon-ship is per encounter -type in config (`encounters.abandon-ship`: PIRATE_CREW 10, SEA_WITCH 30) and -the encounter PDC tag now records the TYPE, not just "encounter". Playtest -check: whether pillagers actually fire crossbows from the deck on 26.2 - if -not, raise their number back toward 30 in config and the boat is arrival -theatre only. - -**Interstice resources are planned, not built** - see -`tradewinds-interstice-plan.md` (normative when work starts): wart shoals, -blaze pickets, glowstone ceiling, quartz spires, wither watchtowers; -netherite loot-only; End goods trade-only; entry stays by-accident -(consolation-prize model). **Shipped now:** lighting a portal frame in either -TradeWinds world prints the lore refusal instead of failing silently -(`tradewinds.interstice.no-portal`) - ruined portals are common enough that -players were reading the silence as a bug. - -## Stage 7b — living with a base (2026-08-05) - -Closes Stage 7. Four features, all riding on 7a's claims: - -- **Member-only warp node.** A claimed islet travels as a SYNTHETIC - `IslandSpec` (`travel/HomePort`): its wild-grid cell, its real centre, and - tech level -1 as the home sentinel (`HomePort.isHome`). Members get it - pinned first in every warp dialog with its own labels (no port sheet - a - home has no type or tech); nobody else ever sees it. Warping OUT triggers - at the claim's protection border exactly like a port's ring - (`BorderPromptListener.homeBorder`). Arrival aims the boat at the islet - centre - there is no pier to steer for. Normal fuel rules throughout. - **Interstice trap dodged:** the owed-destination map stores galaxy CELLS, - and a home's wild-grid cell would re-resolve to a wrong (or no) trading - island - so a failed home warp stores the `"home"` marker instead and - re-resolves through the player's island at re-engage; if they unclaimed - while stranded, the nearest-charted fallback still applies. Nobody is - ever stranded. -- **Course-home mode.** `/tw go` at sea (with an island) now TOGGLES a - persistent course bar (`NavigationBarTask.showCourse`): by day the sun - gives only the eight-point direction; by night the stars add the exact - distance and the bar fills as home approaches (`hud.course-bar-scale`). - Island waters still take precedence over the course bar; making landfall - in your own waters clears the course by itself. The chart holograms - gained a gold HOME marker, and the Star Chart draws the claim as a gold - dot named Home (or the island's admin-given name) for every member. -- **`/tw unclaim`.** Owner only, confirmation required, refuses while the - team has members (kicking the crew is part of the decision), no refund. - Uses BentoBox `hardDeleteIsland` - record gone, grid freed, every block - stays - so the islet is claimable again the moment it returns. Only - islands carrying the `tradewinds-claim` metadata may be dropped: the - spawn island is not yours to unclaim. -- **Ship's compass.** Sold at FISHING outfitters (rod and compass - the - fisheries are the navigator's shop, and the default spawn island is one): - a compass bought by an island member leaves the counter lodestone-bound - to their island with tracking OFF, so it points home from anywhere with - no lodestone block needed. For everyone else it is a plain compass - - which points at world spawn, the spawn port. Not on every shelf because - INDUSTRIAL's armour row already fills the 8-slot dialog exactly - (`SaleCatalogTest.testOutfitterFitsTheDialog` is the guard that caught it). - -## The moored-refit bug: find boats by identity, never by position box (2026-08-04) - -Playtest: exit your oak boat at the dock, buy a spruce hull at the trader, -walk back - still an oak boat. The record said spruce (the exact -record-over-wrong-hull mismatch `refit`'s own javadoc warns about). Cause: -the moored branch searched a **6-block box around the last-remembered -position** - but dismounted boats glide and drift, and the remembered spot -goes stale. Same fragile pattern sat in `relabel` (a 4-block box) and in -`boatIsHere`'s fallback (trusted the remembered position outright, so the -yard could take money for a hull it could not reach). - -Fix: new `BoatService.findPlaced(hold)` - finds the record's placed avatar -(Boat entity, or dropped item carrying the stamp) by **identity** across the -world's loaded entities, and re-remembers the true position on every hit, so -the chart heals whenever anyone looks. All three callers now go through it: - -- `refit` swaps the hull wherever it actually floats (or fixes the dropped - item in place); position box gone. -- `boatIsHere` judges the boat's TRUE position against the island range - a - refit is only sold when the hull is genuinely at the island (carried in - the pack counts, judged on the player's position). Stricter than before, - and what the design always claimed. -- `relabel` labels whatever it finds, at any distance. - -This is the "match boats by identity, never by material" rule's sibling: -never by position, either. - -## Stage 7a — Seafarer ranks & islet claiming (2026-08-04) - -Stage 7 is wild-islet claiming, not blueprints (decided 2026-08-04, dev plan -updated). This session built the 7a half: the rank ladder, the leaderboard, -and `/tw claim`. - -- **Seafarer ranks** (`travel/RankService`): rank = charted-island count - against `ranks.thresholds` (19 rungs, Deck Hand 0 → Mythic Mariner 100); - names in the locale under `tradewinds.rank.`. Early rungs sit above - the pre-charted starter cluster (5), so the first promotion is earned. - `claims.minimum-rank` names a slug; a typo **fails closed** to the top - threshold, never to zero. -- **Leaderboard + placeholders** (`travel/ChartLeaderboard`): counts seeded - from the DB async at startup, kept fresh by `IslandChartedEvent` (both the - sighting and port-scan paths fire it). `/tw rank` shows rank, next rung and - the top ten; PlaceholderAPI gets `%tradewinds_rank%`, `%tradewinds_charted%`, - `%tradewinds_top_name_1..10%`, `%tradewinds_top_charted_1..10%`. -- **`/tw claim`** (`travel/IsletClaimService`): stand on a wild islet, pass - rank gate + price (`claims.price`, default 150,000 — above the top boat), - and the islet becomes a real BentoBox island: owner set, protection sized - `islet radius + claims.protection-margin`, default flags, metadata - `tradewinds-claim` = "cellX,cellZ,radius" for the 7b warp node. No paste - - the land is already there, and whatever was built on it comes along. - Money is withdrawn only AFTER the grid accepts the island: the insert is - the one step that can still refuse, and a refund path is a bug farm. - -**The pitfall that shaped it: BentoBox's grid refuses overlapping RANGE -boxes, and `createIsland` hands every island the full island-distance box** -(1000 → a 2000-block square). Trading islands therefore reserved ~1.1 km -around every port — no islet claim could ever fit there (~40% of all islets). -Fix in two halves: trading islands now get their range box sized to the -protection range at registration AND adoption (`GalaxyIslandRegistrar -.sizeRangeBox`, which must re-add to the island cache or the grid keeps the -old box until restart), and claims size their box BEFORE the grid insert by -mimicking `createIsland` (`new Island(...)` + `setRange` + cache add) instead -of calling it. Both are legal because TradeWinds overrides -`isEnforceEqualRanges()` to false - the hook was left in place for exactly -this stage. Side effect worth knowing: band SETTING flags (PvP, monster -spawns) now reach exactly the protection range, which is what the config -comments always claimed they did. - -Respawn needed nothing: `ISLAND_RESPAWN` is a BentoBox world flag that -defaults ON, so owners/members respawn at their claim; the islandless still -hit `SpawnRespawnListener`'s spawn-port path. - -**`/tw sethome` and `/tw home`** (same day): both work ONLY while standing -inside your own island's protection range - owner or team member. Ruled by -Ben: homes are a convenience around the base, never a way home across the -sea, so the no-teleport rule stands. Both lean on stock BentoBox home -machinery (`setHomeLocation`, `homeTeleportAsync`); the only TradeWinds code -is the gate, shared as `TWSetHomeCommand.islandUnderfoot`. - -**`/tw go` is now the one "go" verb** (ruled 2026-08-05): from another world -it is still the door into the ocean; standing on your own island it steps -you home (same move as `/tw home`); anywhere else at sea, if you have an -island, it POINTS the way - eight-point bearing plus distance -(`TWSpawnCommand.compassKey`, unit-tested) - and leaves the sailing to you. -The islandless still get the original at-sea refusal. This also settles the -bare-`/tw` question: `default-action: go` now does the right thing -everywhere, and no config change is needed. Direction names live in the -locale under `tradewinds.direction.*` (spelled north/south/..., never bare -`n`/`no` - YAML 1.1 booleans). The 7b course mode (persistent bar, stars at -night) will grow out of the same command. - -**`/twadmin rank [rank|islands|reset]`** (same day): rank is DERIVED -from the charted count, so the admin command stores the *difference* from the -player's real chart as a persistent `chartedBonus` on `TWPlayerData` - -promotion and demotion both work, `reset` clears it, and real charting keeps -counting on top. Every consumer (rank, claim gate, leaderboard, placeholders) -reads one number, `TWPlayerData.effectiveCharted()`, so an admin change is -consistent everywhere at once. Built for testing the claim gate without -sailing 36 islands; kept for event rewards. - -Also this session, pre-7a: **deepslate below Y 0** (vanilla look, deepslate -ore variants free from the decoration pass); and confirmed in play that -vanilla ores DO generate under our generator - `make-decorations: true` is -all it takes. - -Still 7b (planned, not built): `/tw home`-style course-home toggle (NOT -`/tw go`, which is the door into the ocean and already taken), member-only -warp to/from the claim, unclaim (owner command, unregister semantics, team -must be emptied first), ship's compass on the outfitter shelf. - -## Known 26.2 quirk: clocks spin in every plugin world (2026-08-03) - -Not ours. Clocks spin (Nether-style) in the TradeWinds world AND in BSkyBlock's -world on the same server; they tick correctly in the main `world`. Paper 26.2 -moved API-created worlds into real custom dimensions -(`world/dimensions/minecraft/`, via `WorldFolderMigration`), and the -client no longer treats them as "natural" for the clock dial - even though -CraftServer's createWorld still selects the vanilla overworld stem -(`natural=true`; verified in the decompiled 26.2 jar). Multiverse records, -gamerules and our createWorlds (stock AcidIsland pattern) are all clean. - -Upstream Paper issue - do not burn time re-investigating from the addon side. -Diagnostic to pin which side broke, if ever needed: a compass spinning + beds -refusing spawn in a plugin world means the dimension registered natural=false -(server bug); compass and beds fine means the clock's client logic changed -(client bug - the likely one, or bed complaints would predate us). - -## The logbook is parked behind a flag (2026-08-03) - -Ben's call after playtest: the price logbook and harbour reports are not -required for MVP. Now behind **`economy.price-logbook-enabled`**, default -false: the command is not registered, market visits do not write the logbook, -the broker button does not appear, and `buyMarketReport` refuses. Four -surfaces, all gated on the one flag. Depth-at-the-counter is untouched - it -works and stays. - -What the playtest showed, for the revisit (full list in the salvage plan's -status note): every port read "best: Gems $850" because the unfiltered view -compares ABSOLUTE prices and diamonds carry the highest base price everywhere - -best needs to be relative to book; the category argument has no tab-complete; -and a $500 report purchase produces one chat line. - -## /tw prices shipped permission-less (2026-08-03) - -Phase 6's two new commands never got their permissions declared in `addon.yml`, -so `tradewinds.island.prices` and `tradewinds.admin.priceaudit` had no default -and permission plugins denied them - "I do not have permission to execute that -command", on the command the harbour report tells you to run. - -Third member of the artifact-drift family, after the config maps BentoBox -replaces rather than merges and the locale keys that render as themselves. -Same shape every time: two artifacts must agree, disagreement breaks the game -and not the build. Same cure: **`PermissionDeclarationTest`** scans every -`setPermission()` call and asserts the prefixed permission is declared in -`addon.yml` with a default and a description. Verified by re-removing the -prices declaration and watching it name it. - -Trap inside the guard itself: permission names contain dots, which -`YamlConfiguration` reads as NESTING - `getKeys(false)` on the permissions -section returns path segments, not permissions. A node is a declaration iff it -carries `default` or `description`. - -## Fuel by mouse, and the marked-coal bug (2026-08-03) - -**Bought coal could not be fuelled.** The purchase mark makes a bought stack -fail `isSimilar` against a plain one, and `moveCargoToFuel` matched with -`new ItemStack(material)` - so the transfer silently moved nothing, and the -withdraw path answered with the one-way message instead. Fixed with a stack- -matching overload; the mark evaporates in the tank (fuel is material-keyed and -explicitly exempt from the one-way rule). Regression-tested. - -**The hold window now moves cargo with ordinary mouse gestures.** Left-click -picks a stack up onto the cursor; drop it on the fuel row to fuel it (right- -click feeds one at a time, like a furnace), drop it in your own inventory to -take it ashore (refused for trader-bought cargo), drop it anywhere else in the -window to put it back. Shift-click keeps the quick route (fuel to the tank, -anything else ashore); right-click still selects for the TNT. - -**The safety property that makes this simple:** the pickup is *cosmetic*. -Nothing leaves the database until the drop lands somewhere meaningful, so a -window closed - or a session dropped - with a full cursor loses nothing. The -three places the cursor copy could have become real are each guarded: every -click in the window is already cancelled; drags are refused entirely while the -cursor carries cargo (a drag over the player's own slots would deposit the -copy); and on close the cursor is cleared before the server can hand the copy -to the player. Render hides the held amount so the stack never shows twice. - -## Money formats itself (2026-08-03) - -"Coal x1 - $27.00 Dollars": the server economy's `format()` put the cents back -and appended a currency name, and the locale added a `$` of its own. Delegating -to Vault for display - my earlier call - loses to reality: TradeWinds rounds -every price to a whole coin precisely so dense dialogs read clean, then handed -the number to a formatter that undid it. - -`Money.format` now renders "$1,728" itself: whole coins, US-style thousands -separators, symbol from **`economy.currency-symbol`** (so a gems-or-credits -server is not stuck with `$`). All 23 hand-written `$` were stripped from the -locale - the symbol lives in exactly one place. Verified every money variable in -every locale string is produced by `Money.format` before deciding this was safe. -Vault still handles the actual accounts; only display changed. `MoneyTest` pins -the format, including that a stray fraction rounds away rather than resurfacing -as cents. - -## Playtest fixes — config drift and telling goods apart (2026-08-03) - -**An oak boat carried 30 slots.** The shipped `config.yml` had every value in -`boats.ranks` multiplied by ten: cargo SLOT counts caught up in the whole-coin -money migration. Slots are not money. Because BentoBox **replaces** map settings -from config rather than merging them, the wrong values beat the correct code -defaults - and every unit test reads the code default, so the build stayed green. - -That is the **second** time config/code drift shipped a broken game this way (the -first was `economy.base-prices` at 27 of 112 entries). So the fix is not just the -data: **`ConfigAgreementTest` now walks every `@ConfigEntry` map in `Settings` by -reflection and asserts `config.yml` agrees with the code default**, key for key -and value for value, comparing numbers by value so `20` and `20.0` are the same -setting. Verified by deliberately corrupting two entries and watching it name both. -A second test asserts the ranks ladder is 20 rungs of 2-21, which is the shape -that a money migration would break again. - -**ClassCastException at the trader.** Integer cannot be cast to Double, from -`base-prices` written as `20` rather than `20.0` - see the commit; fixed both by -writing decimals and by coercing every numeric map in `Settings.asDoubles()`. - -**The sell page redesigned: pick a good, then a quantity (2026-08-03).** The -first attempt put an icon per row above a wall of three buttons per good, which -duplicated every good twice over and still could not put the icon *beside* its -buttons - Paper's dialog API renders items only in the **body**, never in a -button label, and body and buttons are separate regions. So selling is two pages: -- `openSell` lists one row per distinct good, labelled and priced. -- `openSellItem` shows that good's icon at 32px with its real hover tooltip, the - live unit price, the remaining depth, and 1 / 16 / all. - -Selling reopens the item page rather than the list, so **the price and depth -visibly move as the port fills up** - which is the entire reason to sell in -increments rather than all at once. - -**A locale key rendered as itself.** `itemLabel` asked for -`tradewinds.market.item-enchanted` while the dialog helper prefixes -`tradewinds.ui.` - so the button read "tradewinds.market.item-enchanted x1". -A missing key does not throw; BentoBox renders the key. That is the **third** -locale failure of this kind after YAML-boolean key names and a silently dropped -duplicate, so **`LocaleKeyTest` now scans every `.java` source for locale keys and -asserts each resolves to a leaf string in `en-US.yml`**. It strips comments and -`@ConfigEntry` paths (both quote keys that are not lookups), ignores concatenated -keys, and asserts the four runtime-built page keys explicitly rather than skipping -them. Verified by renaming a key and watching it fail. A new `uiText()` helper -shares the prefix with `ui()` so the two cannot drift again. - -**Three identical "Iron Sword" rows on the sell page.** With NBT preserved, the -enchanted sword and the two plain ones were three rows of identical text at -different prices, and nothing said which was which. The sell page (and the -secondhand shelf) now render **one `ItemDialogBody` per row, in the same order as -the buttons, with `showTooltip(true)`** - so hovering gives the item's real -tooltip, enchantments and all. Buttons are far too narrow for an enchantment list, -so they get a short marker instead (`✦` for enchanted, `(worn)` for damaged, or -the item's given name if it has one). - -Paper's dialog API does support item icons - `DialogBody.item(ItemStack)` with a -description and a real tooltip - but only in the dialog **body**, never in a -button label. Hence icons above, markers on the buttons. - -## Stage 7.5 Phase 7 — the secondhand shelf (2026-08-03) - -Notable goods sold to a trader go back out for sale instead of vanishing, so the -world feels inhabited: *someone dumped a Silk Touch pick at Baker's Reach*. - -- `TWIslandData.shelf` is a bounded `List` (item + listed-at), swept by - `economy.resale-ttl-hours` (72) on access, `economy.resale-slots` (2) deep, with - the oldest listing making way for an arrival. -- **Only notable goods resurface**: enchanted, renamed, or worth at least - `economy.resale-notable-value` (500). Ordinary cargo is not interesting to find - and would bury the things that are. -- **They surface at a DIFFERENT port** than the one they were sold at, within - `economy.resale-ship-radius` (8000) - the trader shipped it on. This is the - load-bearing part: an unpredictable destination is what stops shelves becoming - an alt-account laundering channel. The destination is *seeded* from the item and - the selling port rather than random, so it cannot be re-rolled by retrying. -- `economy.resale-markup` (1.6) sits above book price, and a test asserts the - shelf price exceeds what the same port would PAY for the item - otherwise buy - from the shelf, sell at the counter, repeat. -- Shelf purchases arrive **marked as trader-bought**, so they are subject to the - one-way rule like anything else a market sells. - -**Ordering detail worth keeping:** `buyFromShelf` adds to the hold *before* -removing the listing, so a full hold cannot destroy a one-of-a-kind item. - -## Stage 7.5 Phase 6 — price discovery (2026-08-03) - -Two different questions, and only one of them is hard. - -**At the counter you know everything.** The sell tooltip now states the *depth*: -"this port will take about N more at that price", from Phase 1's -`absorbableValue` divided by the unit price. That is the number a seller actually -needs, because it says when to stop selling and sail on. - -**Across the map you should not know.** A **logbook**, not an oracle: -`TWPlayerData.priceLog` (island -> category -> price seen) plus `priceLogSeen` -(when), written whenever a player opens a port's market. `/tw prices` lists ports -you have *called at* with their age; `/tw prices metals` sorts by best price for -one kind of goods. Prices for ports never visited are simply not knowable, and -stale readings show their age - which is what makes a well-travelled trader more -capable than a new one rather than merely richer. - -Category-level rather than per-material, because that is the granularity prices -vary at: type, tech, band and drift all move a whole category together. - -**Harbour reports** (`economy.market-report-price-per-island` 250, -`market-report-radius-per-tech-level` 1500) let a broker fill in your logbook for -ports within the selling island's tech-scaled reach. This gives a developed port a -role beyond its shelves and a reason to call at a hub you are not trading with. -No boat needed - it is information, not cargo. - -**Deviations from the plan, both deliberate:** -- The logbook surfaces through `/tw prices` rather than on the chart holograms. - The gameplay value ("where do I take this?") is identical, the risk is far - lower, and the hologram renderer can adopt the same data later. -- **Plaza rumours were not built.** They were the cheap flavour version of - exactly what the logbook and harbour reports now do properly, so they would - add a third route to the same information. Worth revisiting only as flavour. - -## Stage 7.5 Phase 5 — the hold works both ways (2026-08-03) - -A scavenger has to be able to use their hold as a hold. **This narrows a -load-bearing rule**: "cargo leaves the hold only by sale or destruction" now -applies to **trader-bought cargo only**. - -- `travel/CargoMark` marks bought cargo in the item's own persistent data - (`tradewinds:traded`). Because the mark lives in the meta it survives the - hold's serialisation, travels through a boat capture, stays visible if a bug - ever leaks one into an inventory - and makes marked and unmarked stacks fail - `isSimilar`, so one bought diamond can never lock up twenty mined ones. -- `HoldService.withdraw` returns **-1** for trader-bought cargo rather than 0, so - the GUI can say *why* instead of just refusing. Overflow drops at the player's - feet rather than vanishing. -- Hold GUI gestures: **left-click takes cargo out**, shift-click sends burnables - to the fuel row (the old plain-click behaviour), right-click selects for the - TNT. The border pane's lore states all three. -- `MarketService.buy` marks what it sells you. Selling and destroying are - untouched - `remove()` is not gated, only `withdraw()`. - -**Bug found while doing it:** the existing `deposit` path added -`hold.add(player, material, amount)`, so loading an enchanted sword into the hold -stored a *plain* one. Harmless before Phase 4 (nothing had NBT to lose) and -silently destructive after it. - -**Pitfall:** `CargoMark` first built its key with `new NamespacedKey(plugin, ...)` -cached in a static - which needs a mocked plugin and outlives a reload. -`BoatService` already had the right pattern: `NamespacedKey.fromString`, no -Plugin instance at all. - -## Stage 7.5 Phase 4 — the hold carries NBT (2026-08-03) - -The hold stores **one ItemStack per slot** instead of material→amount, so a worn -bow, a mint bow and a Silk Touch pick are three different goods. - -- `BoatHold.contents` -> `cargo` as `List`; `expanders` as - `List>`. **Fuel stays material-keyed** - a lump of coal is a - lump of coal. BentoBox's `ItemStackTypeAdapter` (registered in - `BentoboxTypeAdapterFactory`) persists these through Bukkit's own YAML - serializer, so meta survives a restart; its 99-amount clamp cannot be hit - because a slot never holds more than a stack. -- New `travel/CargoStore` holds all the slot arithmetic - capacity, consolidation, - drain, compaction - as statics, which is what makes it testable. -- `HoldService` keeps its Material-based API as thin overloads over the - ItemStack ones, so the buy path, fuel and starter kit were untouched. -- `MarketService.basePrice/playerBuysAt/playerSellsAt/sell/handlesValue` now take - ItemStacks. The sell page groups by *distinct good*, so two differently - enchanted swords are two rows at two prices. -- `PriceEngine` applies durability (already written), **enchantments** ( - `getEnchantmentValue` existed and had never been called - there was never an - enchanted item left to price) and **potions** via `PotionMeta`. New knobs - `economy.enchantment-price-factor` 0.05 and `economy.potion-effect-price` 150. -- `HoldGui.Selection` carries the ItemStack, not the Material - otherwise - selecting an enchanted sword and hitting the TNT destroyed a plain one. - -**MockBukkit findings that shaped the design** (probed rather than assumed - the -CLAUDE.md note that "ItemStack meta does not work" was too coarse): -- `isSimilar`, `getMaxStackSize`, `clone` and `equals` all work. -- `new ItemStack(...)` attaches a phantom **UNSPECIFIC** meta and `clone()` drops - it, so a cloned plain item stops being `isSimilar` to an identical one. Hence - `CargoStore.copyOf` rebuilds plain items from their type and only clones items - with real meta - which is better production code anyway, and gives the same - answer in both worlds. `isPlain` compares against a pristine stack rather than - trusting `hasItemMeta()`, which lies here. -- `new ItemStack(Material.AIR)` throws `AbstractMethodError`: never manufacture - an air stack, only detect one. -- ItemStack **serialisation is dead** headlessly (`craftDelegate` is null), so - persistence of enchanted cargo is a manual check in `TESTING.md`. Potion - pricing is likewise manual-only - `PotionType`'s effect registry is not - available in tests. - -**Pitfall:** Maven does not recompile unchanged test sources, so four tests -failed at *runtime* with `NoSuchMethodError` against the new signatures rather -than failing to compile. If a rename looks like it broke tests mysteriously, -that is what happened. - -## Stage 7.5 Phase 3 — tech gates what a port will handle (2026-08-03) - -`economy.salvage-value-per-tech-level` (1500) caps the value of a single salvage -item a port will deal in: a TL1 hamlet takes mob loot but not diamond-grade gear, -a TL7 hub takes anything. Loot therefore has a *destination*, and a destination -is a voyage. Refused at the counter with `tradewinds.trade.too-advanced`, and -hidden from the sell page the same way contraband already is. - -**Design correction made during implementation.** The plan said to gate on value -by tech, full stop. Applied to everything that would have had a low-tech LUXURY -island refusing the very diamonds it demands - gems are demanded by LUXURY but -are not on its produce shelf, so a flat cap catches them. The gate therefore -exempts recognised trade goods entirely: a port always deals in what its shelves -stock, whatever its tech. That keeps the existing trade economy untouched (again) -and leaves the gate doing only the job it was added for, which is turning exotic -loot into a reason to sail somewhere. - -## Stage 7.5 Phase 2 — salvage has a market (2026-08-03) - -Scavenged loot is now sellable, at a discount, into a stock pool of its own. - -- **`TradeCategory.SALVAGE`**, which `of()` never returns: salvage is decided by - whether a material is on any island's shelves (`TypeEconomy.tradeGoods()` - - the union of every type catalog and outfitter shelf), not by its name. That - leaves the existing trade economy completely untouched; an iron ingot is cargo - exactly where it always was. No type produces or demands SALVAGE, so it is - affinity- and tech-neutral, and `economy.salvage-discount` (0.375) is applied - on top. -- A **separate stock pool** is the load-bearing part: without it, dumping a - boatload of junk would crater the same category's honest cargo prices for every - trader at that port, which is a griefing vector on a small server. -- **~50 natural drops priced** (mob loot, foraged food, dug blocks). None of - these has any recipe, so the engine could never derive them however deep it - recursed - they had to be stated or a scavenger's whole haul was unsellable. -- **`/twadmin priceaudit`** writes a full coverage report (unpriceable / salvage / - trade goods) to `price-audit.txt`. - -**The real bug behind the complaint.** BentoBox **replaces** `Map` settings from -`config.yml` rather than merging them (`YamlDatabaseHandler.deserializeMap`), and -the shipped `config.yml` carried **27 of 112** base prices. So ~50 goods were -unsellable on every real server while the unit tests - which read the code -default - passed happily. The config block is now complete and generated from the -code table, and `SettingsTest` fails the build if the two ever drift again. A -second new test asserts every price key resolves to a real `Material`, which also -catches Minecraft renames like `SCUTE` -> `TURTLE_SCUTE`. - -**Why the audit is a command and not a unit test.** The plan called for a -headless coverage test, but most prices are *derived* from crafting recipes and -MockBukkit ships no vanilla recipe set - headless, nearly everything reports -unpriceable, which is a confident wrong answer. The live registry is the only -place the truth lives. The headless guards test what they can actually see: name -validity and config/code agreement. - -## Stage 7.5 Phase 1 — the trader's purse (2026-08-03) - -Planned in `tradewinds-salvage-plan.md`. Stock drift was counted in **items**, -so ten diamonds and ten wheat hit a port identically and a stack of diamonds -was one payday rather than several ports' business. Drift is now denominated in -**coins of inventory position** - a port's capacity is its capacity to *spend*. - -- `TWIslandData.stock` -> `stockValue`; `IslandDataManager.adjustStock` -> - `adjustStockValue(spec, category, coinDelta)`, fed the transaction total from - `MarketService.sell`/`buy` (its only two call sites). -- `economy.drift-scale` 500 -> `economy.drift-value-scale` **30000**; - `economy.stock-decay-per-hour` 50 -> `economy.stock-decay-value-per-hour` - **3000**. Renamed rather than retuned in place so nobody has to guess the unit. -- New `IslandDataManager.absorbableValue()` - headroom before the price floor. - This is Phase 6's "depth at the counter" number, built now because the drift - clamp is what defines it: only the first `(1 - driftMin) x scale` coins move a - price at all, so saturation is 9000 coins and recovery about 3 hours. - -**Tuning check** (demanded good, SAFE band, no drift): 7 diamonds, 12 emeralds, -84 iron ingots, 391 wheat or 3000 cobblestone saturate one port. That puts a -stack of diamonds at roughly nine ports' worth of business and a boatload of -cobble at about half a port's - which is the intended shape: value-dense cargo -is what forces the tour, and bulk junk was never going to pay well anyway. - -**Pitfall avoided:** the saturation/headroom math is in pure package-visible -statics (`saturationValue`, `absorbable`) beside the existing `decayed`, because -`IslandDataManager`'s constructor builds a real `Database` and is awkward to -mock. Same reason `decayed` was already shaped that way. - -## Whole coins, and the economy's own formatter (2026-08-03) - -"Oak Log x1 - $0.97" made every market chart ragged. Two independent faults: - -**We never asked the economy how to write money.** Vault exposes -`fractionalDigits()` and `format(double)`, and BentoBox surfaces the latter as -`VaultHook.format()` - an admin running a whole-coin economy has stated a -preference. TradeWinds hand-formatted `%.2f` in 19 places and overrode them -everywhere. All of it now goes through `economy.Money.format(addon, amount)`, -which delegates to Vault and falls back to whole units when there is no -economy. Two local copies of the same helper (TWFineCommand, CustomsService) -were folded in. - -**Prices are now whole coins.** Naive rounding would have broken the cheap end -- sand/stone/kelp sat at 0.2-0.35 after the produce factor and would have -rounded to FREE - so every money value is scaled x10 (base prices, starting -balance 2500, boats `250 x slots²`, expanders 50000, fines, bounties) and the -unit price is rounded **directionally**: buying `ceil`, selling `floor`, buy -never below 1. The direction is load-bearing: rounding both to nearest lets a -same-island round trip break even on some prices, which is free money. -Verified by brute force over 2000 base values x every type factor - zero -violations - and pinned by `testEveryPriceIsAWholeCoin`. - -Cost of the x10 scale: the cheapest goods carry up to ~10% rounding error -(sand's true 2.07 becomes 3). x20 or x100 would shrink it if play says it -matters. Live servers would need balances scaled to match; ours is wiped -anyway. - -## The boat IS the hold: capture, abandonment, protection (2026-08-02) - -Design session with Ben settled the abandonment rules, and they forced the -structural change the last refit left open: **the hold is now keyed to the -BOAT, not the player.** Capture gives the pirate "everything in it", an -abandoned boat keeps its cargo while you row back to it, and trading needs -the ship present - none of which works with a player-keyed hold. The two -records collapse into one `BoatHold` (material, cargo, fuel, expanders, -owner, last-seen position, item TTL) keyed by a UUID stamped in PDC on BOTH -the entity and its item form; `TWPlayerData` just points at `activeBoat` and -`oldBoat`. `PlayerHold` and `DroppedBoat` are gone. - -**The rules as built:** -- **Trading gate:** no ship inside that island's PROTECTED space, no market. - Carrying the boat as an item counts (it is with you); otherwise the record's - last-seen position answers. -- **Capture by boarding or by pickup**, always behind a confirmation dialog - - because the boat you leave behind keeps YOUR cargo and becomes unowned. - Item pickups cancel first and prompt (15s suppression, since pickup fires - every tick you stand near a hull). Passengers never capture. A player with - no boat claims silently - nothing to lose. -- **Smaller hull:** take the goods, leave the hull to the sea and its TTL. -- **Protection:** an OWNED boat left unattended in protected island space - cannot be boarded or broken by anyone else, mobs included - except at - ANARCHIC ports. **Unowned boats are never protected, anywhere**, which is - what makes an OLD BOAT a race to recover and dying with a loaded ship at a - safe dock genuinely costly. -- **Exactly one OLD BOAT** is remembered; older abandonments are forgotten - flotsam. Reclaiming it clears the marker. -- **Death leaves the boat where it is** - no item drop, still yours. Lava - alone destroys boat and cargo together. -- **Name plates:** `Entity extends Nameable`, so the hull carries the owner's - name or UNOWNED, hidden while ridden. (Vanilla name TAGS only work on mobs; - the API has no such limit.) -- **Respawn/login:** a raft when your own ship is out of reach; a stolen-boat - message and a raft for anyone left treading water at login. -- `/tw chart` now raises BOAT and OLD BOAT holograms anywhere in the world. - -**Trap hit:** `yes:`/`no:` are YAML 1.1 BOOLEANS as keys - the capture dialog -locale keys parsed as `true`/`false` and blew up the locale marker test. -Renamed to confirm/cancel. Never use bare yes/no/on/off as locale keys. - -**Warp arrivals face the dock (2026-08-02):** the boat (and sailor) now leave -a warp pointed at the destination's pier, computed from the same seeded dock -plan the quay is built from, so holding forward is the approach. **A hull -sits ACROSS its yaw, not along it** - the first cut used the plain look-at -yaw and arrived broadside ("turn 90 clockwise and I would be pointing at the -dock"), so `boatYawToward` leads the heading by a quarter turn and normalises -back into Minecraft's (-180, 180]. The plain `yawToward` keeps its own tests -as the honest compass maths. - -**Offered to swap straight back (2026-08-03, playtest):** taking a boat from -the ground leaves the old hull at your feet, and walking over it immediately -asked whether you would like to swap back. The existing per-hull prompt -suppression could not help - the shed hull is a *different* boat, seen for the -first time - so a swap now opens a 5-second quiet window during which boat -items are ignored entirely (the pickup is cancelled, not allowed through, or -the spare would land in the pack). Re-pocketing your OWN boat still works -throughout: that branch runs before the window is checked. - -**Jigsaw blocks left standing in islet structures (2026-08-02, playtest):** a -camp on an islet was held up by a row of glowing jigsaw blocks. The templates -were chosen as "single-piece NBT" precisely to avoid this, but that was wrong: -inspecting the shipped NBT shows the pillager camp features AND ruined portals -1/2/3/4/5 all carry jigsaw connectors. Vanilla's assembler swaps each for its -own `final_state` while building; a raw paste does not. - -Rather than read `final_state` at runtime (Boxed does this via BentoBox's NMS -metadata helper, which needs a real Block and so does not fit a -BlockPopulator), the value is read from each template's NBT once and recorded -on the `Placement`: camps and portal_3 become AIR, portals 1/2/4/5 become -NETHERRACK. `IsletDecorator` then scrubs the pasted box - JIGSAW to that fill, -STRUCTURE blocks to air. A test asserts every placement names a real material -and that the netherrack-footed portals say so. - -**Crafted boat came out blank (2026-08-02, playtest):** an acacia boat crafted -as an upgrade had no lore and would not open. Clicking a crafting result puts -the item on the **cursor**, not into a slot, and the stamping pass only walked -`getInventory().getContents()` - so the new hull never got its record id. It -now checks the cursor first (and retries a few ticks later if the item has not -landed anywhere yet). Because an unstamped hull can never be opened, the hold -GUI also self-heals: right-clicking a boat item with no id, of exactly your -boat's type, while carrying no other avatar of it, adopts it as the avatar. -That repairs any hull that reaches a player without its record, whatever the -route. - -**Chart marking your own pocket (2026-08-02, playtest):** after a few boat -swaps the chart showed BOAT 8m and OLD BOAT 8m - both pointing at the -player's feet, one of them at the boat in their hand. Markers are now only -raised for a boat you actually have to GO to: never one you are carrying -(`BoatService.isCarrying`), never one already under you, and never one within -32 blocks. A hull deliberately shed during a merge (emptied, dropped at your -feet) also stops being charted as an OLD BOAT - you did not lose it, you put -it down. - -**Two boats in one pack (2026-08-02, playtest):** recovering an old boat while -carrying the current one left BOTH hulls in the inventory - and since the -hold GUI matched the boat item by MATERIAL, either oak hull opened the active -hold. The spare could then be dropped, picked up and stripped for free fuel, -or thrown to show up as an OLD BOAT. Three fixes: taking a boat while your -old one is **with you** (carried, or moored within loading range) now pours -its cargo into the new hull and leaves the empty hull at your feet, unowned - -you end up with the best boat and all the cargo, per Ben's ruling; the GUI -gesture matches by boat IDENTITY, not type; and crafted hulls are stamped -with their record, so a freshly built boat is not an anonymous item that -merely looks like your ship. When the old boat is far away nothing changes - -it stays put with its cargo as the OLD BOAT you row back to. - -**Paid $100 for a raft and got nothing (2026-08-02, playtest):** the yard's -purchase only knew how to REFIT - it changed the material on the record the -player already had. With no boat there was no record, so `ifPresent` did -nothing at all: money gone, no hull. Buying with no boat now creates the -record and hands over the item; buying WITH a boat is a genuine refit that -also swaps the avatar in the world (ridden entity replaced under the sailor, -carried item retyped, moored hull rebuilt where it floats) - the old code -changed only the record, so a refit would have left an oak hull claiming to -be a Cherry Chest Boat. A refit now also requires the ship to be AT the yard -(a trade-in needs the old hull present); buying your first boat never does, -since that is the escape hatch for a stranded sailor. - -**Capture left the victim holding a ghost (2026-08-02, two-player playtest):** -one capture produced five bugs, all from a single omission - **taking a boat -never told the boat's owner.** Player 2's record still pointed at the hull -Player 1 had taken, so: no notification at the time; phantom "0/3 slots" at -the market; low-fuel nagging with no boat; and at login the stolen-boat path -called `setActiveBoat(null)`, which *demotes* - so it stripped the new -owner's name (handing the boat back!) and left a bogus OLD BOAT marker 16m -away. The database showed it plainly: both players' `oldBoat` pointed at the -same hull, and Player 1's real abandoned boat was orphaned. - -Fixes: `setActiveBoat` now takes the boat off its previous owner (clearing -their pointer and telling them if online); **losing** a boat goes through a -new `clearActiveBoat` that does NOT demote (only abandonment leaves an OLD -BOAT); `oldBoat()` returns nothing - and forgets the pointer - once the hull -is owned again or gone; fuel warnings skip boatless players; and the market -gate now applies to CARGO only, so the outfitter and shipwright always serve -(a sailor who lost their boat could otherwise never buy another and was -stranded on the island for good). - -**Tests now run the real thing:** these rules had been "verified" against a -hand-written stand-in for HoldManager, which is why none of it was caught. -`TestHolds` now drives the REAL manager over an in-memory database, so the -ownership regressions are pinned against production logic. - -**No BOAT marker while aboard (2026-08-02):** pointing a sailor at the deck -under their feet is noise, so the green BOAT hologram is suppressed while -riding. OLD BOAT still shows - that is the one being rowed back to. - -**Cargo teleported across the ocean (2026-08-02, playtest):** a hull thrown to -a player standing on ANOTHER island stripped itself into their boat thousands -of blocks away, and they could not take the hull at all ("I really wanted the -boat"). Both faults came from the automatic size-based salvage. Finding a hull -- by pickup or by boarding - now always opens one dialog with up to three -answers: take the boat (your own becomes an unowned OLD BOAT), take only the -cargo (**only** while your own boat is within `boats.cargo-transfer-range`, -default 200 - and the dialog says WHY the option is missing when it is not), -or leave it. A boatless finder still claims outright with no dialog. This -supersedes the earlier "smaller hull: goods only, leave the hull" ruling: the -size of the hull no longer decides anything, the player does. - -**Warp facing, settled by console (2026-08-02):** the "hull sits across its -yaw" theory was WRONG and is reverted. The logging proved it in one warp: -arriving (-2919, 2187) with the dock flag at (-3237, 2497), the computed -look-at yaw was 45.8 and the sailor's own F3 read 46.3 when they turned to -face the dock - so `yawToward` had been right from the start. The real fault -is that **mounting drags the facing** (the boat ends up pointing wherever the -player is looking; the log showed player yaw 178.8 against a boat set to -135.8). Both rotations are now forced with `setRotation` two ticks AFTER the -re-seat instead of trusting the teleport, and a regression test pins the real -numbers from that console line. Confirmed working in play; the yaw -diagnostics were removed once they had done their job (the one-line arrival -log stays). Lesson: one empirical report ("turn 90 -clockwise") is a symptom, not a diagnosis - the offset it suggested was a -coincidence of that arrival's geometry. - -**Chart holograms vanishing (2026-08-02):** "/tw chart shows them, then they -quickly disappear." Every `show()` scheduled a fade for the configured -duration, but the timer cleared whatever was current rather than its OWN set -- so an earlier call's timer wiped a later call's holograms seconds after -they appeared. Each set now carries a generation number and a timer only -clears its own. Arrival also raises the chart a second time once the arrival -BLINDNESS wears off, which is why they were "sometimes" invisible on warp in. -Warp arrivals additionally log the dock-flag position, the look-at yaw, the -boat yaw set, and - a tick after the re-seat - what the yaw actually ended up -being, so the remaining facing question can be settled by console rather than -by eye. - -**Playtest round 2 (2026-08-02):** `/tw chart` ashore printed the text list - -useless for the one job ashore actually has, finding your moored boat. The -hologram compass now raises anywhere in the world (text behind -`/tw chart list`). And the capture flow relabelled the abandoned boat BEFORE -`setActiveBoat` stripped its owner, so the plate kept the old owner's name - -relabel now happens after the switch. - -**Playtest immediately after (2026-08-02):** "I dropped the boat in the water -and tried to enter it - it asked me to TAKE my own Oak Boat." Placing a boat -item spawns a plain vanilla entity carrying none of the item's PDC, so the -hull in the water was an unregistered stranger: boarding it registered a new -unowned record and offered a capture, which demoted the real starter boat -(coal and all) to OLD BOAT - hence the phantom marker 56m away and the -missing starter fuel. Fixed with an `EntityPlaceEvent` handler that carries -the record id from item to entity as it is placed. The same identity loss -existed on the teleport path (`BoatPickupListener` built a bare ItemStack), -now stamped; the chest boat's real inventory is no longer rescued there -because the hold is the record. - -241 tests green. - -## The great refit: virtual hold, One Boat, tech levels (2026-08-01) - -The playtest rework, from `tradewinds-hold-plan.md` (normative) + session -rulings. Clean slate - **the test server's TradeWinds DB and world must be -wiped on next deploy** (pouches, stamped goods and real-container holds are -all meaningless now). - -**Tech levels (galaxy).** `IslandSpec.techLevel()` 1-7, seeded, type-based -(+/-2 wobble; INDUSTRIAL/LUXURY base 5, farms/fisheries 2), starter cluster -guarantees one >= TL3 (deterministic boost of the best natural roll if seed -luck fails). Prices tilt by `economy.tech-price-step` (3%/step from TL4): -high tech sells finished (METALS, FOOD) cheap, buys raw (ORES, CROPS, WOOD, -FISH, STONE) dear - contraband exempt. Tech shows in the market subtitle, -warp tooltips, chart, holograms, nav bar. Plazas furnish by tech: galley -everywhere; furnace+stonecutter TL3, smithing+grindstone TL4, brewing+cauldron -TL5, anvil TL6, enchanting table with bookshelf arc TL7. - -**The virtual hold.** `PlayerHold` (DB): boat, cargo material→amount, fuel, -expanders. No real container ever holds cargo; ItemStack meta does not -survive deposit (by design - cargo is a commodity). `HoldService` is the only -gate: slots = ceil(amount/stack) consolidated; one-way in (containers, -bundles, shulkers and BOATS refused); out = sell or TNT-destroy only; 7 fuel -slots, fuel free both ways. `HoldGui`: 54-slot render-and-command window - -every click cancelled and interpreted (deposit from pack, select stack → TNT, -fuel withdraw, expander panels). Chest-boat REAL inventories are now -unreachable by design (sneak-click and in-boat right-click open the hold GUI -instead) - no shadow storage. - -**One Boat.** 20 ranks (config `boats.ranks`), Bamboo Raft 2 slots → Pale Oak -Chest Boat 21. Shop: quadratic `25 x slots²`, lists bigger-only, gated -rank <= TL x 3; purchase REPLACES and destroys the old boat, contents stay. -Crafting: smaller-or-equal refused, bigger is a replacement upgrade -(chest-add included); tech never gates crafting. Start kit: Oak Boat + coal -in the fuel slots. Charity: Bamboo Raft. `/tw restart` clears the whole hold. - -**Stamping is GONE.** Contraband has its own list -(`illegal-trade.contraband-materials`); the market buys anything aboard. -Balance now rests on capacity + stock-pool drift - **watch the pools in -playtest** (farm-and-sell is legal now; drift-scale 500 / decay 50/hr may -need tightening). - -**Dropped boats.** Boat items carry their hold via a PDC UUID onto a -`DroppedBoat` record; DB-persisted TTL (`boats.dropped-boat-ttl-minutes`), -sweep task sinks expired flotsam; vanilla despawn cancelled for tagged items. -Breakage always yields the item (destroy event cancelled, entity swapped for -a drop) - lava alone burns boat and hold. Death drops the tagged boat -(keepInventory keeps it; DeathChest chests it; fire/lava deaths burn it). -Pickup: no boat = claim whole; bigger = SWAP (ruled in session: your old boat -becomes the floating item, carrying whatever overflow did not fit - the old -hull can always take it, so nothing is ever lost to the sea); smaller/equal = -most-valuable-first partial transfer, remainder stays claimable. Handing a -loaded boat over IS the trade gesture. Shop purchases remain trade-ins (old -boat destroyed) - only salvage swaps. - -**Expanders v2.** Install-only into a Pale Oak Chest Boat with a free slot -(each occupies one), TL7 shops only, `5000 x 2^installed`, no cap. Nested -21-slot panel per expander (own TNT, no fuel row, no nesting); TNT refuses a -loaded expander; in any smaller boat they ride inert, contents intact, still -counted aboard for trade and customs. - -**Post-rulings (same session):** riding + inventory key opens the hold on -CHEST-variant boats (the client sends the vanilla open-vehicle-inventory -request and we redirect it - this also seals the chest boat's real inventory -completely). Plain boats CANNOT get this gesture: the client renders the -ordinary inventory screen without telling the server, so the right-click -gestures cover them. Also: bigger-boat salvage is a SWAP (old boat -floats on with the overflow - nothing lost to the sea, conservation-tested); -fuel-valued cargo clicks MOVE to the fuel row instead of selecting for the -TNT (fuel is exempt from one-way, and the TNT never touches fuel, per plan). - -**Respawn loaner (2026-08-02):** a boatless respawner is lent -`boats.respawn-boat` (default BAMBOO_RAFT, NONE disables) - death maroons you -ashore while your boat floats at the death site; the raft is the row back. -Never granted over an existing boat (keepInventory), and off-ladder config -values disable rather than misfire. - -**Duplicate locale keys (2026-08-02):** the console warned "duplicate keys -found : description / nothing-to-sell". Both were self-inflicted by bulk -locale edits: the `commands.trade` subcommand had been left indented under -`starchart` (so its `description` collided and the trade command lost its -help line), and renaming `not-stamped` to `nothing-to-sell` collided with an -existing key of that name - two different messages, one for "this port wants -none of what you carry" (dialog) and one for "you have none of THAT" -(market), so the market's got its own key `nothing-of-that`. Bukkit only -WARNS and then silently keeps one, so `ResourceYamlTest` now fails the build -on any duplicate key in addon.yml, config.yml or any locale. - -**Plain-boat pickups + no lingering hulls (2026-08-02, playtest):** "picking -up a given oak chest boat didn't upgrade." The ladder now applies to UNTAGGED -boat items too (command-given, spare hulls, other players' plain drops): -bigger replaces (no record = no overflow = old hull consumed outright), -boatless claims, smaller stays an ordinary item. And the salvage swap only -leaves the old boat afloat when it actually carries overflow - an empty old -hull no longer lingers. - -**Visible boundaries (2026-08-02, playtest):** "there is no visible border - -it's invisible." A per-player dust-curtain task now paints the arc nearest -you of both rings: RED at the warp-offer ring (protection edge), BLUE at the -island-space edge. Colors/view distance/master switch in `border.*`; parsing -is forgiving (typo = default, never a stripped border). Paint, not a wall. - -**First cut showed nothing** (warp dialog fired, no particles). Rewritten to -follow the Border addon's proven path exactly: `User.spawnParticle` (which -resolves DUST/REDSTONE across versions, validates the dust options, applies -the server view-distance check and passes extra=1 - a raw -`player.spawnParticle(..., extra 0, dust)` was the difference), and the -curtain now hangs around the PLAYER's own Y rather than an assumed sea level, -so a boat sitting above the waterline still sees it. Startup logs a line with -the ring radii and on/off state, so the console says whether it is alive. -Ruled out first: config default (BentoBox `config.contains` gating means a -missing path keeps the Java default `true`, so an old config cannot silently -disable it) and task registration (start() is in onEnable). - -**Removed:** pouches, customs stamps, the carried-shulker expander, -`ExpanderListener`, `economy.unstamped-sellables`, `stamp-glint`, -`expander-cap`, `max-bundles`, `pouch-price`, `TWPlayerData.expandersPurchased`. - -229 tests green. - -## The galley: every plaza cooks (2026-08-01) - -Every market plaza now has a **public workbench and a campfire hearth** beside -the quay entrance (bearing + 0.7 rad, plazaRadius − 4), so a sailor can craft -and cook their catch the moment they step ashore. No flag changes were needed: -CRAFTING is already visitor-rank at every port, campfire interaction is -governed by BentoBox's FURNACE flag (also visitor-rank), and BREAK_BLOCKS is -denied - usable by all, removable by none. The campfire stands on a -cobblestone hearth block so residents do not path across open flame. A -campfire rather than a furnace on purpose: it needs no fuel, so even a -destitute sailor can cook. No economy leak either way - anything crafted or -cooked is unstamped and therefore unsellable; the galley feeds players, not -wallets. - -226 tests green. - -## Ground truth, and ruins worth rowing to (2026-08-01) - -**Per-biome surface materials.** With every biome now generatable, "everything -stands on grass" stopped being ignorable: a desert islet was a lawn with a -desert sky. `surfaceKindAt` is now purely biome-driven - it asks `biomeKeyAt` -(which already knows islet vs island vs beach fringe vs mushroom) and maps -through one table: sand under deserts and beaches, red sand over terracotta in -the badlands, podzol in old-growth taiga, mud in mangrove swamps, bare stone -on stony shores and the peaks, gravel on the gravelly hills, snow blocks on -groves/slopes/ice spikes, mycelium on mushroom fields. Trading islands follow -the same rule (a desert INDUSTRIAL port is a sand island now); plaza and dock -terraforming still override their own columns. `SurfaceKind` grew from 3 kinds -to 9; `IslandPalette` maps them (subsoil too: sandstone under sand, terracotta -under red sand). NOTE for the live world: the spawn island's mangrove biome -means its newly generated chunks are mud-surfaced - a seam against -already-generated grass chunks is expected on the test server. - -**Islet structures.** Wild islets can now carry a small vanilla structure at -their heart, chance per islet in config (`galaxy.islet-structure-chance`, -default 0.25). Selection is pure and seeded (`galaxy.IsletStructures`): -igloos on the snowfields, half-buried fossils in the deserts/badlands/swamps, -ruined portals (loot chests intact) in jungles and woods, abandoned pillager -camps (tents, log piles, target ranges) on plains and savannas. Placement is -`generator.IsletDecorator`, a BlockPopulator that stamps the whole template -down when the islet's center chunk generates - single-chunk anchored, so no -cross-chunk consistency to maintain, and safe to toggle mid-game. -**Deliberately restricted to vanilla's single-piece NBT templates** (fossils, -igloo/top, ruined_portal/*, pillager_outpost/feature_*) - jigsaw-built -structures (villages, temples) would leave connector blocks behind if placed -raw; that is Stage-2 territory via the Boxed patterns. Mushroom islets and -pale gardens never decorate: there the biome itself is the find. - -226 tests green. - -## Every land biome now has somewhere to exist (2026-08-01) - -"Is it possible to find a Cherry Grove islet or Pale Oak islet?" It was not: -wild islets drew uniformly from a list of 8 temperate biomes, and 21 of the -registry's 40 generatable overworld land biomes existed nowhere at all -(pale_garden, taiga, grove, bamboo_jungle, eroded_badlands, the peaks...). - -Wild islets now draw from **temperature-banded lists** keyed to -`oceanTemperatureIndex` at the islet's center - the sea the galaxy already -varies. Frozen seas grow snowfields, groves and frozen peaks; cold seas taigas -and windswept hills; temperate seas the forests (cherry grove and pale garden -among them); lukewarm the savannas and sparse jungle; warm seas desert, -badlands, bamboo jungle and mangrove. Between the five bands and the trading -island types, every land biome is reachable (caves, rivers, oceans, Nether and -End deliberately excepted), and the land always suits the water it stands in. -`isletBiomes()` aggregates the bands, so the biome provider declares the new -keys with no further change. Trading island type biomes are untouched. - -Caveat for the live world: islet biomes in not-yet-generated chunks re-roll -under the new scheme, so a chunk border through an already-half-generated -islet could show a biome seam. Trading islands are unaffected. - -220 tests green. - -## The expander that would not open (2026-08-01) - -**"Right clicking a cargo expander does not open its inventory."** Two -findings, one report: - -**The gesture players actually try was never implemented.** The listener only -knew the in-hand gesture (expander in the main hand, right-click the world); -the natural one - inventory screen open, right-click the expander where it -lies, like using a bundle - fell through to vanilla's pick-up-half. There is -now an `InventoryClickEvent` handler for it: own inventory only, empty cursor -only, opens the same 27-slot view next tick. The view write-back is tracked by -SLOT, and registration now happens only after `openInventory` confirms the -view really opened - registering first meant the close of a view being swapped -out could have its contents written into the box. - -**The in-hand gesture was also broken, invisibly.** `PlayerInteractEvent` for -a click at AIR is *born cancelled* - `isCancelled()` is defined as "block use -denied", and with no block that half is permanently denied - so the -`ignoreCancelled = true` on `onRightClick` skipped every click at sea or at -the horizon. The handler now checks the half that means something for a held -item: `useItemInHand() == DENY`. **Pitfall for every future -PlayerInteractEvent listener: never pair `ignoreCancelled = true` with -RIGHT_CLICK_AIR handling.** - -218 tests green. - -## Arrivals reopened the warp dialog, and the dock sign sank (2026-08-01) - -**"Warping in triggered the exit warp."** Moving arrivals to the border (below) -overshot: 400 blocks is exactly the protection range, which is exactly where -the warp-offer ring sits - so every arrival landed ON the ring and the dialog -the sailor had just come through reopened in their face. Two fixes, either of -which suffices: the arrival default is now 320 (inside the ring's inner edge at -protection − trigger = 370, with margin for the outward open-water correction), -and `BorderPromptListener` listens for `TWWarpCompletedEvent` and seeds its own -prompt cooldown at the destination - so even a config that lands arrivals on -the ring cannot reopen the dialog. Live configs need `arrival-distance: 320` by -hand; the cooldown seeding protects the ones that keep 400. - -**"The DOCK sign is in the water."** Chart hologram heights are relative to the -player's feet, and a sailor in a boat has their feet at sea level - the dock -marker's 0.2 offset put it *in* the sea ten blocks out, below the horizon. -Dock marker raised to 1.5 and the island-name stack base to 2.5, preserving the -design (dock lowest, names stacked above, 0.8 per rank) with everything above -the waterline. - -Also learned in the same playtest: the SAFE-cluster produce→demand triangle -(Belege → Teenla → Esedaxe on the test seed) earns as designed - "a bit of a -grind, but makes safe money." - -211 tests green. - -## Patrols that never ticked, and a false escape (2026-08-01) - -The dispatch logging earned its place immediately - one paste of console output -answered three questions at once. - -**"The mobs spawned but nothing happened."** Not BentoBox protection, which was -the natural suspect: they spawned fine, 200 blocks away at the pier, and -`simulation-distance=10` means anything past 160 blocks **never ticks**. They -did not swim, chase or do anything at all. Launching literally from the quay -was right in spirit and wrong in practice. The launch point is now pulled along -the line from pier to smuggler until it is close enough to be alive -(`patrol-distance`, now 80 - inside both the simulation distance and the -96-block entity tracking range). It reads as a patrol that has already rowed -most of the way out. - -**"I was next to the dock and it said I'd reached open water."** The 120-second -chase timeout called the same `escaped()` as a genuine border crossing - same -message, same flee flag. So a player who never ran was told they had escaped -and was flagged at the port for it. Timeout is now its own outcome: the patrol -gives up and turns for home, and nothing is remembered against you. - -**"I arrived too close - I'd expect to be near where the warp dialog pops up."** -Correct, and the number was indefensible: 130 blocks is *inside* the island's -own 160-block terrain radius, on its underwater shelf. Arrival is now 400, the -island's visible border - the same place the dialog offers itself on the way -out, so arriving mirrors leaving. (The 130 came from earlier feedback that the -paddle in was too long; the navigation bar and the dock hologram have since -solved being lost, which was the real complaint.) - -211 tests green. - -## Patrols launch from the dock, and leave bystanders alone (2026-08-01) - -"Again instant ambush." Twice now I had answered that by moving the spawn -further from the player - 22 blocks, then 45 - and twice it was still an -ambush, because the distance was never the problem. **The origin was.** A -patrol that materialises near its quarry is an ambush at any radius. - -Customs now launch from the **pier end** of the island's own quay, which is -where a harbour's boats put out from, and swim after the smuggler. You can see -them coming and outrun them, which is what the decision window in spec section -6 is for. - -That change forced another: with the swimmers starting at the dock they will -never close on a boat, so a customs patrol now includes a **phantom**. Without -something that can genuinely pursue, "run" stops being a choice and becomes the -answer every time. (Ben also asked where the phantoms were - they only existed -in the *wanted* roster, never the customs one.) - -**Police no longer hurt bystanders.** Targeting was re-asserted every couple of -seconds, but a mob can swing between ticks and a guardian's beam locks on -before any of that runs. The damage itself is now refused unless the victim is -the one the law actually wants - wanted, or being chased right now. A patrol -that hurts whoever is moored nearby is not policing, it is weather. - -**Logging**, as asked: the warp arrival prints the player's coordinates and -distance from the island centre, and every dispatch prints the player position, -the pier position, the launch point, and each unit's type, coordinates and -distance from the player. - -**Pitfall:** a scripted replace on `PoliceRoster` silently did not match, and -the script cheerfully printed "roster ok" anyway. The test caught it, but only -because it asserted on the roster contents. Never print success from a -replacement without checking it happened. - -211 tests green. - -## Contraband in pockets, and patrols that ambush (2026-08-01) - -Two from the same playtest. - -**"Will any sugar in any of my inventory slots be checked?"** It was not - only -the hold. That read as principled, since cargo means the hold everywhere else -in this game, but it handed smugglers a free pass: tip the sugar into your -pockets before the border, cross, stow it again on the far side. The whole -mechanic bypassed with two inventory clicks. Customs now search hold *and* -pockets, and confiscation takes from both. A customs officer searches the -sailor, not just the cargo manifest. - -**"The guardians were instantly on me."** The patrol surfaced 22 blocks away -and a guardian's laser reaches 15, so it was firing before the warning had -finished printing. That is an ambush, not the decision window section 6 asks -for. Now 45 blocks (`illegal-trade.patrol-distance`), and the scatter around -that point can no longer pull units back inside it either. - -**"I warped really close to the island too, which seems odd."** Measured: 37 of -864 approach bearings (4.3%) put the arrival on or beside land, because a warped -coastline can now reach the 130-block arrival ring. The fix from the -suffocation bug searched for water in *every* direction, which happily corrects -by moving the sailor inward into a bay - the exact symptom. Arrivals now step -**outward** along the approach bearing only, and require elbow room rather than -any single water block. Worst case push: 20 blocks. - -211 tests green. - -## The interstice: lit, lidded, and ghasts you can see (2026-08-01) - -Ben: "very dark and dim... the ghast(s) I hear spawn when I failed - I never see -them, are they really there, or was it just the sound?" - -**They were really there, and I had made them invisible.** Yesterday's fix for -players dying on arrival pushed the spawn distance out to 90 blocks so engaging -would be a choice. But the monster **entity-tracking-range** is 48 on a default -spigot.yml (96 on this server), so a ghast at 90-120 blocks is never sent to the -client at all - and since the same fix stopped giving them a target, they had no -reason to close the distance either. The result was a scream (which I play -myself on arrival) and nothing else, forever. Distance is now 44, comfortably -inside tracking range on any server, with the grace window and the -sometimes-nothing roll still doing the work of making arrival survivable. - -A good reminder that "far enough away to be fair" has an upper bound set by what -the client is ever told about. - -**Still too far (same day).** Two more faults behind that. The spawn code added -`random * 30` blocks on top of the configured distance, which quietly undid the -setting - a base of 44 was arriving as far out as 74. It is now a tight band -around the value (0.85-1.15x), and the default is 28. And nothing ever re-aimed -the ghasts: spawning them without a target stopped arrival being an ambush, but -they then drifted and the encounter simply never happened. Once the arrival -grace expires, anything nearby now acquires the castaway - the free way out is -always on offer, so staying is the choice that carries consequences. - -The re-engage dialog also gained a **"Stay a while"** exit. It had exactly one -button, so the only way to put it down was to take the warp - and a player who -wants to look at the sea first should be able to. - -**And the dark.** An open black sky over a black sea reads as unfinished rather -than hostile, so the interstice now has a **ceiling** (netherrack under bedrock, -48 blocks up) and **braziers**: netherrack outcrops rising out of the water with -fire burning on top, roughly one chunk in six. Fire on netherrack burns forever -and there is nothing out there for it to spread to. They are the only light in -the place, and they double as landmarks - somewhere featureless is disorienting -in a way that somewhere dangerous is not. - -**Pitfall:** a careless string replace turned the live config's -`ghast-distance: 90.0` into a stray `.0` appended to the next key -(`brazier-chance: 0.18.0`). Parse the YAML after editing it, every time. - -210 tests green. - -## Server crash: hot-swapped jar, and a chunk-loading warp arrival (2026-08-01) - -`NoClassDefFoundError: IslandPalette` inside chunk generation, which Paper -escalates to an unrecoverable chunk system failure and stops the server. - -**Cause: my deploy.** The jar was written at 09:30:56 and the class failed to -load at 09:31:41, 45 seconds later, mid-session. The plugin classloader reads -classes lazily out of the jar file, so overwriting it under a running server -invalidates the handle and anything not already loaded is simply gone. -`IslandPalette` is only touched when a column turns out to be dock or plaza or -land, so it can go a long time unloaded - and then a warp arrival generated a -chunk near an island and it was needed. `scripts/deploy.sh` now refuses to -install while the server is up, and CLAUDE.md says why. - -**And a real bug it exposed.** The crash landed in chunk generation *triggered -by* `SeaArrival.openSeaNear`, which read blocks - `world.getBlockAt(...)` - -spiralling out to 160 blocks. Reading a block in an ungenerated chunk forces a -synchronous load, so a single warp arrival could make the main thread generate -dozens of chunks in a row before the teleport even began. It also meant a -generation failure anywhere in that spiral took the server with it. - -None of that was necessary: the sea floor and the island masks are pure -functions of (seed, position), so "is this open water?" is arithmetic. The -search now asks the galaxy - floor below sea level, and no dock or plaza -terraformed over the column - and touches no blocks at all. The interstice -passes a null engine: no islands, no docks, and a floor that cannot reach the -surface, so the intended point always serves. - -210 tests green. - -## The Star Chart is an instrument, not cargo (2026-08-01) - -Ben: "When I did starchart I got given another one. I fear these could end up -littering." - -Right on both counts, and the old `give()` was worse than described - it -dropped any inventory overflow on the ground, so a full pack meant a map -floating in the sea. Rather than making the command idempotent and leaving a -permanent item to manage, the chart is now **ephemeral**: it exists only while -you are looking at it. - -It goes straight into a free hotbar slot which is then selected, so the -"put it away and it is gone" rule applies from the moment it arrives. Switching -slots reclaims it, dropping it destroys it, dying does not yield it, and -logging out takes it with you. Running the command twice reclaims the first, so -there is exactly one ever. - -The slot-change rule only fires when the slot being *left* held a chart, so a -chart sitting elsewhere is never snatched before its owner has looked at it. - -209 tests green. - -## Dialogs close under attack, and no warping out of a fight (2026-08-01) - -Ben: a dialog should exit if you are being attacked, with a bed-style action -bar - "cannot warp while enemies are close". - -A dialog is a modal screen: reading the warp list while a patrol closes in -means not seeing the boat, the water, or the thing shooting. Any damage now -closes any open dialog - deliberately *any* damage, since drowning while -reading a shop menu deserves the same treatment. - -The warp gate is the more interesting half, because it turns a UI nicety into a -rule: a warp is no longer a panic button out of a fight, which makes the -customs chase mean what section 6 says it means. Caught with contraband, the -choices are run, fight or jettison - warping away would have been a silent -fourth option that beat all three. Checked in three places, because the fuel is -spent at engagement: when the dialog opens, again when a destination is -clicked (a patrol can arrive while the menu is up), and during the stand-still -countdown, where it refunds. - -**The interstice re-engage is exempt, and that exemption is load-bearing.** It -is the way *out* of a place designed to be dangerous, and gating it behind "no -enemies nearby" could strand a player permanently - the exact failure the free -re-engage exists to prevent. It goes through `deliver` rather than `warp`, so -it never meets the check; that is now written down in both classes so nobody -"tidies" the two paths together. - -209 tests green. - -## Star chart: fuel range ring, reachability, readable names (2026-08-01) - -Three playtest asks, all about the chart telling a sailor what they can -actually do rather than only where things are. - -**The fuel range ring.** A warp costs fuel per block of route, so the reachable -set genuinely is a circle - which is a thing a chart can draw and a number -cannot. Dashed, so it reads as an annotation rather than a wall, and drawn -before the islands so dots and names stay on top of it. It is skipped entirely -when the radius runs off the canvas: a ring clamped to the edge would be a lie -about your range, and worse than no ring at all. - -**Reachability in `/tw chart list`.** A list of places you cannot afford to go -is a list of disappointments. Each entry now carries its fuel cost, green when -reachable and greyed with "(not enough fuel)" when not, under a "Fuel aboard" -line. Standing at a port the figure is the exact route price, lane overrides -included; adrift it is the same distance-based estimate measured from the -player, which is close enough to plan by. - -**White names.** `MapCanvas` takes its text colour from a -section-sign/palette-index/semicolon prefix, and with no prefix the default was -a mid grey that all but vanished against the ocean blue. - -209 tests green. - -## /tw go: a door into the ocean, not a teleport (2026-08-01) - -Ben spotted it: "Now that Spawn is a trading post, which is a good thing... -the /tw spawn command is a free way to warp back there!" Exactly right, and it -is the kind of exploit that only appears when two good decisions meet - spawn -became a real port so new players would trade there, and the free teleport that -was harmless when spawn was an empty islet turned into a free ride to a market -from anywhere in the galaxy. - -First cut simply unregistered it. Ben caught the hole immediately: *"so if I'm -in another world and I run /tw, how do I start?"* Deleting the exploit had -deleted the front door - a new player, or anyone standing in another game -mode's world, had no way into the ocean at all. - -So it is a door with two rules instead. It **refuses while you are already at -sea** - that is the exploit, and the whole of it. And coming in from outside -returns you to the water you **left**, not to spawn. That second rule matters -more than it looks: this server runs several game modes, so a spawn-anchored -door would have re-opened the same free ride through the side - `/acid`, then -`/tw`, and you are standing in a market. `SeaPositionTracker` records the spot -on quit and on any teleport out of a TradeWinds world, and only a sailor who -has never set out starts at the spawn port. - -Recording uses `PlayerTeleportEvent`, not `PlayerChangedWorldEvent`: by the -time the latter fires the player has already moved, so their location is the -destination - the one position that is no use. - -Labelled `go` (aliases `spawn`, `sail`), and both default actions point at it, -so bare `/tw` is the natural entry. - -Death still respawns at the spawn plaza (you paid for that trip with your -cargo), and `/tw restart` still returns a destitute player there, capped. - -205 tests green. - -## Low fuel warning, and the band on the boss bar (2026-08-01) - -Two playtest asks, both about the player not being told something they needed. - -**Low fuel.** Running dry at a port is not a soft failure - the only way onward -is rowing - and it is only fixable while the sailor is still standing next to -the fuel. So the check happens at the port: fuel aboard against the *cheapest* -charted route out, since affording the far island is irrelevant if you cannot -afford the near one. - -Ben's own caveat drove the design: "for kids, they won't read it if it isn't -obvious". So it is told three times, in three different lifetimes: -- the **action bar** repeats every 8s while ashore and short (a one-shot would - be missed by exactly the players this is for), -- a **chat line** fires once per port and *stays* in the log, with how much more - fuel is needed, -- the **market dialog** relabels whichever button actually sells fuel to - "BUY FUEL HERE" - the outfitter normally, or the buy page at islands whose own - catalog stocks fuel, since the outfitter only carries charcoal when the trade - catalog does not. - -No warning when nothing is charted: being unable to warp is not a fuel problem -then, and saying so would be a lie. - -**The band on the boss bar.** "I arrived in an anarchy and I couldn't remember -what kind of island it was, and couldn't work out how to tell." The bar colour -already tracked the band, but a colour with no label is a puzzle. The bar now -reads *name | band | standing | dock*, with the band coloured in the locale -(blue Safe through bold dark-red ANARCHIC) - and the same coloured name now -appears on chart holograms and the market subtitle. Worth noting the bar's -"Clean" was the *player's standing* all along, which is easy to misread as -something about the island. - -205 tests green. - -## "Suffocated in a wall": warp arrivals landing inside the quay (2026-08-01) - -Ben's death screen: *"BoxManager suffocated in a wall whilst fighting Ghast"*, -with the overworld navigation bar showing **Edatge | Dock 136m**. The ghast was -a red herring - it was the recent-damage attribution from the interstice, still -credited a few seconds after he re-engaged the warp and left. - -Two hypotheses died before the real one. First: vanilla nether decoration -scattering blocks in the interstice's open air. **Checked it** by parsing the -interstice region files - 37,024 sections above y=72 and not one non-air block. -Second: ragged coastlines reaching past the arrival ring. Also wrong - a sweep -of every bearing around every island within 12,000 blocks found the arrival -point never lands on natural terrain. - -The actual cause was in the nav bar all along. A warp arrives at a fixed 130 -blocks from the island centre at **sea level + 1**, and the quay runs out to 136 -blocks with its plank deck at **exactly that height**. Line the approach bearing -up with the dock bearing - 0.9% of bearings do - and the warp materialises the -sailor inside the decking. The arrival code never asked what was there. - -`SeaArrival` now finds the nearest **open water** to the intended point -(searching outward, nearest first) and puts the arrival there, for both warp -arrivals and interstice strandings. Arrivals are by boat; open water is the only -sensible answer. The regression test asserts the quay collision exists *and* -that natural land does not, so the next person to read it does not go re-fixing -the coastline. - -Also added `/twadmin warpfail `: rigs a player's next warp to fail. -A 5% chance is miserable to reproduce on demand and the interstice needs testing -far more often than that. It toggles, and the flag is consumed by the next warp -either way, so it cannot sit forgotten on an account. Doubles as a live tool for -spicing up a session. - -**Pitfall:** two plausible explanations, both wrong, and each would have led to a -real but pointless change. Parsing the world file took a few minutes and settled -it - measuring beat guessing again. - -200 tests green. - -## Interstice: a failed warp was killing new players (2026-08-01) - -Playtest: "I was warping and got sent to the Nether. The ghasts immediately -started to attack... a dialog box popped up that I was too frightened to read -... I just died and lost everything after one warp." - -The numbers were indefensible. Ghasts spawned at **20-35 blocks** - well inside -a ghast's 64-block detection range - and `setTarget(player)` was called on -arrival, so they were already hunting before the player had finished loading in. -There was no chance of an empty interstice: at least one always came. A 5% warp -failure meant a new player's first jump could cost them the boat, the cargo and -the kit, for nothing they did wrong. - -The interstice is meant to be a **detour with a way out**, not a death sentence -(spec 3.3: the fuel is already spent, so re-engaging is free). Three changes: -- `interstice.ghast-chance` (0.6): sometimes nothing comes at all. Dark water - and a long silence is unsettling on its own, and it means a failed warp is not - automatically a fight. -- `interstice.ghast-distance` (90): ghasts appear **beyond their own detection - range** and are no longer given a target. They are a thing you can see and - decide about, which is the same rule the sea encounters already follow. -- `interstice.grace-seconds` (20): for a short window nothing may target or - damage a new arrival, so the dialog can actually be read. It also covers a - fireball already in flight - clicking "re-engage the warp" and dying to a shot - fired before you read it is precisely the reported experience. - -The grace covers the arrival only; stay and pick a fight and it is a fight. - -197 tests green. - -## Stage 6c — police, wanted response, bounties (2026-08-01) - -Closes Stage 6. The law now answers for itself: while you are inside a policed -island's waters and wanted, patrols come. - -`PoliceRoster` holds the decision and is pure, so the shape is testable without -a server. Units are picked by **mobility**, because each denies a different -escape (spec section 7): golems take anyone ashore, guardians hold the water, -and **phantoms are the only thing that can follow a boat**. A sea response -without a phantom is a response you simply row away from - there is a test -asserting every roster can pursue. - -Three properties keep this from becoming a nuisance or a farm: -- **Break off at the border.** Police give up ~400 blocks past the protection - range. Without it an escort would follow a player across the ocean and the - security bands would flatten into one difficulty everywhere - lawless water - has to be genuinely where the law is not. ANARCHIC sends nobody at all, which - is the entire reason to run out there. -- **No leaks.** Units are recalled on break-off, on logout, on world change, on - paying a fine, and on ceasing to be wanted; they are non-persistent, so an - unloaded chunk takes them too. -- **No drops.** Already enforced for every tagged unit since 6b. - -Police phantoms do not burn at dawn - a pursuit that ends because the sun came -up is not a pursuit - and an `EntityTargetEvent` guard keeps patrols off -innocent bystanders. - -**PvP override**: BentoBox's own PvP listener cancels player damage at LOW -priority, so the override un-cancels it at NORMAL when the victim is a lawful -target. Without it a wanted player could moor in a SAFE band and be -untouchable, and bounty hunting would only work where it was least needed. - -**Bounties are visible now**: a scoreboard team suffix beside the name while the -bounty is above zero. A bounty nobody can see is not a bounty. Because that -fights TAB-style nametag plugins, it is config-gated and PlaceholderAPI -placeholders are exposed alongside (`bounty`, `bounty_raw`, `standing`, -`reputation`, `wanted`) so a server can render it wherever it already owns the -name. - -**Fugitive trade bar** was specified in 6a (`Standing.isBarredFromSafeTrade`) -but never actually enforced at a market - the method existed and nothing called -it. Now the dialog refuses to open at safe ports for a fugitive, which is -principle 4 arrived at from the other direction: smuggling pushes you outward, -and so does burning your name. - -**Pitfall:** a `str.replace` on the locale for `" customs:"` also matched the -six-space-indented admin `customs:` block and corrupted the YAML. Anchor -replacements on a newline plus the full indent, and re-parse the file -afterwards - which is how it was caught. - -197 tests green. - -## Stage 6b — customs and contraband (2026-07-31) - -The scan on entering island space, and the chase after it. Detection is -deliberately **not** a fine (spec section 6): being caught at the border starts -a pursuit, and the water between you and the horizon is a decision - **run**, -**fight**, or **jettison**, which clears you at the cost of the cargo and -leaves it floating for anyone to take. That last one is the seed of piracy. - -`CustomsService` owns the loop. Entry is detected by tracking which island's -protection range a player is inside and firing on the transition, so rowing in -and warping in are the same event - the spec asks for a scan on *every* entry, -and a teleport is an entry. - -Two counters stop the obvious abuses, both from the spec: -- a **per-island scan cooldown**, so a smuggler cannot bounce across the border - re-rolling the dice until they get a pass; -- a **flee flag**, so running is remembered: return to that port inside 20 - minutes and there is no roll at all, the patrol just launches. - -The risk/reward shape has an invariant worth stating out loud, and there is now -a test for it: **the bands that buy contraband are the bands that do not scan -for it**. Safe ports search everyone and refuse to deal; anarchic ports do -neither. If that ever inverts, smuggling becomes either free money or -impossible. Standing tilts the odds both ways, which is where "positive rep -must pay" finally bites. - -`PoliceDispatch` is the minimum force that makes a chase real - a sea patrol -surfacing *between* the smuggler and the port, so running for open water is a -live option and running for the harbour is not. Stage 6c builds the rest on it. -Police are PDC-tagged, non-persistent, and **drop nothing at all**: loot-bearing -police would turn a criminal record into an iron farm, which is exactly -backwards. - -Contraband selling is band-gated (`safest-contraband-buyer`, default FRONTIER), -so a smuggling run is a voyage outward rather than a shortcut. - -187 tests green. Open question 5 (scan/flag tuning) now has defaults to argue -with rather than blanks: 10 min scan cooldown, 20 min flee flag, 4-block arrest -radius, 400-block break-off, 120-second chase cap. - -**First playtest: "not sure if it is working" - four real bugs.** -1. `` inside a `[title]` rendered as a literal line-feed glyph. BentoBox - splits the string on the raw `[subtitle]` marker and hands each half to - MiniMessage separately, so `` became an actual newline character - inside a title component - and a title is one line by definition. The - subtitle IS the second line. `CustomsLocaleMarkerTest` now walks every locale - string and fails on a newline in a title half, a marker that is not at the - start, or a `[subtitle]` with no `[title]`. -2. A title flashes past and is gone. Every customs alert now also sends a chat - line, which persists. -3. **Warping away mid-chase silently disabled customs for the session.** The - chase stayed live, and `onEntry` refuses to start a second one - so the - destination never scanned, and neither did anywhere afterwards. Leaving an - island's space now ends that island's chase (as an escape) *before* the new - island is considered. -4. Logging in inside a port counted as an entry, and the patrol spawned on the - plaza - guardians flopping on dry land. Login now records position without - scanning, and a patrol that can find no water to launch from turns into a - straight confiscation, which is what being caught ashore in a market should - mean anyway. - -**Second playtest: the reward half was missing, and the chase was unrunnable.** -- "Sugar trading should be high risk, high reward - but the trader only offered - ~$1 for it." Correct, and a design hole rather than a tuning nit: contraband - was priced from its crafting recipe like every other good, and sugar's recipe - price is about one currency unit. The risk was built; the reward never was. - Contraband now carries a **black-market premium** - (`illegal-trade.contraband-price-multiplier`, default 8.0) and counts as - *demanded* at any port that deals in it, so the existing band bonus applies - and running further out pays more. Measured: $11.90 at FRONTIER rising to - $14.28 at ANARCHIC, against an honest margin of $6.38 per item on a mid-value - good - roughly double the return per hold slot, with no capital outlay, which - is what "high risk, high reward" has to mean. A test pins the shape. -- Traders no longer **stock** contraband. If they did, a player could buy it - over the counter at the honest price and sell it back at the premium with no - farming and no risk - a money printer hiding behind the new premium. -- "`/twadmin customs` reports that the port does not buy it, but they did." The - report was right and the dialog was wrong: the sell page quoted a price for - contraband at ports that would refuse it at the counter. It now omits them. -- "When I arrived, I got instantly hurt and lost the sugar. No idea what - happened." The water search for a patrol spawn fell back to the player's own - position with no minimum distance, so patrols materialised alongside the boat, - opened fire, and the chase tick registered an arrest within one tick. Patrols - now keep a standoff of at least three times the arrest radius. A chase you - cannot run from is not a chase. - -Also added `/twadmin customs`: contraband aboard, standing, band, effective scan -chance, patrol size, chase state. Scans are probabilistic and cooldowns are -invisible, so "is it working?" was not answerable from behaviour alone - that -was the real complaint, and a diagnostic is the answer to it. 190 tests green. - -## Stage 6a — the law: port flags, reputation, fines (2026-07-31) - -**The flag audit, finally done properly.** Three separate playtest bugs (no boat -interaction, no chest boat, no workbench) had the same root cause: BentoBox -protection flags default to MEMBER rank, and **nobody is ever a member of an -unowned island**. Counting it out: of 96 protection flags only 6 default to -visitor rank, so 90 things were denied to everyone on every trading island, and -I had been allow-listing them one bug report at a time. - -So `PortFlags` inverts the policy. A trading island is a **public market**: -everything is allowed at visitor rank except an explicit deny list, and that -list is now the design statement, grouped by why - -1. anti-grief (the plaza and dock are the only hand-built terrain in the world), -2. **the two economies** - an island's crops, stores, hive and livestock are - denied because harvesting them would mint money outside trade margins - (spec 5.0), which no amount of playtesting would have surfaced as a *bug*, -3. `TRADING`, so a right-click on a villager cannot bypass the market and the - hold (principle 1) - the one I am most glad the audit caught, -4. livestock and residents, 5. portals. - -It walks `Flags.values()` at runtime rather than naming an allow list, so a flag -added by a future BentoBox is open at a port on day one instead of silently -locking something. Admins get `bands.port-denied-flags` / `port-allowed-flags` -either way. - -**Reputation core.** `ReputationScale` (pure, headless) owns the number line and -the bands; `Standing` owns what each band *means* to the world - hunted, lawful -target, barred from safe trade - so nothing downstream tests thresholds by hand. -`ReputationService` records crimes, fires `TWReputationChangeEvent`, and runs -decay. Decay is credited only while **online and in a TradeWinds world**, so a -week offline does not launder a reputation, and it walks toward zero from either -side without overshooting. - -Three speeds of recovery, per spec section 7: slow decay, `/tw fine` at a port, -and positive acts later. A fine returns you to **Clean and no further** - money -buys you out of Wanted, not into virtue - and a Fugitive is refused at SAFE -ports, so buying your way back costs you a trip into danger. Principle 4 in -reverse. - -`CrimeListener` carries the anti-bait guard: the innocent-kill penalty is -forfeit if the victim struck first within 30s, or standing near a wanted player -and dying at them would be a weapon. - -**Pitfalls:** -- I audited the flag list against BentoBox **HEAD**, but the compile target is - 3.18.1, which has a different flag set (no `FISHING`). Runtime iteration over - `Flags.values()` handles the drift; naming flags in code does not. Check the - jar, not the source, before naming a constant. -- `getPlayerStanding` had to take a `User` - a standing is player-facing text - and must be translated for the *viewer*, not the subject. -- 182 tests green. 6b (customs/contraband) and 6c (police/bounty payout, - nameplates) still to come; the bounty ledger already clears on payout so it - can only ever pay once. - -## The sea itself: seabed shape, vanilla structures, denser islets (2026-07-31) - -Ben, after covering 130 regions: "I have not found a single islet. Nothing... -the sea floor is really barren and repetitive." Both were real, and they had -different causes. - -**Islets were generating — just too rarely to meet.** The math was sound (a -probe over the live seed found 357 in a 40,000-block square, the nearest 977 -blocks from spawn) but at grid 1200 x chance 0.3 the mean distance to one was -well over a kilometre, and each was a fixed 70-block cone, so a whole voyage -could pass between two of them and see neither. Now grid 900 x chance 0.55, -and every islet rolls its own radius (0.55x-1.55x the mean) and its own -height, so the sea holds sandbars through to proper little islands. Measured -on the live seed: **mean 544 blocks to the nearest islet, worst case 1,376** -(was well over 2,000). `testIsletsAreFindable` now asserts < 1,200 from four -far-flung points; the old assertion allowed 2,500, which is why it never -caught this. - -**The barren floor was one flat Perlin field**, and worse, it was the wrong -tool: the floor is not scenery, its depth is what picks the ocean biome, and -the biome is what tells vanilla where monuments, ruins and coral belong. So -the seabed moved into the pure galaxy package (`Seabed`, `SeabedConfig`) where -terrain and biome read the same field: -- **Basins** (2,200-block lattice) take the floor from sunlit shelf to abyssal - plain. Depth over the deep-water threshold switches the biome to the - `deep_*` variants — which is the whole unlock for ocean monuments. -- **Relief** (220) rolls dunes and hills over it. -- **Rifts**: ridged noise (value noise folded about its midpoint) cuts narrow - wandering canyons up to 26 blocks below the surrounding floor. -- **Seamounts** rise off the deeper plains only, and are capped so they never - break the surface — the galaxy's islands stay the world's only land. -- **Island shelf blend**: near any island or islet the natural floor eases to - a standard 18-block shelf, so an island that lands over an abyss still sits - in shallow water and still clears the waves by the same amount. A bonus: - the shoreline radius is now exact rather than cut about by floor noise. - -Measured over 53,000 open-sea samples on the live seed: depths spread 8-63, -**39.5% deep water**, and all nine ocean biomes present (frozen through warm, -shallow and deep). - -**Vanilla now furnishes the sea.** `make-caves` and `make-structures` default -true — carvers cut caves under the floor, and structure placement supplies -shipwrecks, ocean ruins (warm and cold, by biome), monuments (deep basins), -buried treasure and trial chambers (in the rock under everything). The hybrid -is: we own the shape of the floor, vanilla furnishes it. Structures are -suppressed per-chunk over trading islands via the per-chunk -`shouldGenerateStructures(WorldInfo, Random, int, int)` overload, so nothing -drops through a market plaza; wild islets are fair game. - -Islets also gained a sandy beach ring at the exact waterline (which is what -lets vanilla beach shipwrecks and bury treasure) and a 6% chance of being -**mushroom fields** — mycelium, mooshrooms, no hostile spawns. - -**Breaking the circle (same day, from a playtest screenshot).** Ben: "almost -comically circular". Two causes, both mine: -1. The land mask is a cosine of *true* distance from the center, which is a - perfect disc by definition. Fix: warp the distance with a seeded noise field - before the mask ever sees it (`shapedDistance`) - the same mask then draws - bays and headlands, for one noise lookup. The warp scales with the island's - radius, so a sandbar and a trading island are equally ragged for their size. -2. Worse, the island shelf blend I had just added levelled *everything* near - land to a flat shelf, which is what made the pale shallow ring a perfect - circle too. Now only the **basin** is levelled toward the shelf - that is - all the "islands always break the surface" guarantee needs. The rolling - relief carries on across the shelf at half strength; rifts and seamounts - fade out (a canyon through an anchorage helps nobody). - -Land also got `hilliness`: a noise multiplier on the lift, so an island is -hills and hollows rather than a smooth dome. Multiplied into the lift so it -fades at the shore instead of calving fragments off into the sea. - -Everything that reads the island footprint had to move onto the same warped -distance or the biome would have drawn a circle over the ragged terrain: -`islandAt`, `biomeKeyAt`, the icy approach ring, `shelfBlendAt`, `isletNear`. -Neighbour searches widened by `searchMargin()` to match, or a column in a -headland is missed. - -Beaches were rewritten while I was there. They were a geometric ring at an -analytically exact shore radius - which only worked while the shelf was dead -flat. Now `isShoreAt` just asks the finished terrain whether it is 1-4 blocks -above sea level, so the beach follows the real waterline however ragged the -coast, and needs no geometry at all. - -Verified by rendering islands as ASCII height maps before deploying: outlines -are lobed, the spawn island's quay still runs out to open water (pier end at -y=59, sea level 70). `galaxy.coast-roughness` / `galaxy.island-hilliness` -expose both, and `ShapeConfig.ROUND` restores the old coins. - -**Pitfall (twice now):** averaging fbm octaves pulls a field toward its middle. -The first coast warp used `Noise.fbm` and lost most of its range - the coast -came out nearly round anyway (reach 31-46 on a radius-73 islet). Two explicit -full-range `Noise.at` octaves instead. Same trap as the basin field earlier the -same day; worth remembering that fbm is for *shape*, not for amplitude. - -**Sealing the carvers (same day, from a playtest screenshot).** Turning vanilla -caves on opened dry craters straight through the sea floor - carvers have no -idea there is an ocean overhead, and a generated chunk gets no block updates, -so nothing ever flows in to fill what they cut. Ben pointed at Poseidon, which -hit this and fixed it in `generateCaves`: the API guarantees vanilla's carvers -run *before* that hook and that the ChunkData handed to it already contains -their work, so it is the place to repair them. -Poseidon fills every carved space under the sea with rock. TradeWinds narrows -that, because caves under the seabed are wanted: it recomputes the true floor -top (pure function, so it is exact) and puts back only a 5-block **crust** - -air at or above the floor becomes water again, air just below becomes the same -sediment or rock the floor is made of, and anything deeper stays hollow. Caves -survive, with a sea floor over them; break in from below and they flood, which -is what a player expects. Cave mouths in an island's flank above the waterline -are left alone. - -**Pitfalls:** -- The test helper reads the topmost *solid* block, which is `floorTop - 1` in - generator terms (blocks are written for `y < floorTop`). Worth remembering - when writing terrain assertions. -- Stacked fbm octaves pull toward the middle: the first cut gave only 16 - blocks of depth variation across a long transect — still barren. The basin - field is now stretched about its midpoint and eased, restoring real shelves - and real abyss. -- Bulk-filling base rock up to the chunk's *lowest* floor top left dead flat - chunks (a market plaza, or level sea floor) as bare stone: every column's - surface loop had zero iterations. The fill now stops 8 blocks short. Caught - by `testPlazaAndDockTerraform` — worth having kept that test honest. -- The seabed hangs off the **galaxy** seed, not the world seed, so one number - still decides the whole world (spec principle 5). `testDeterminism` had to - stub a second engine to prove it. -- Stored config values beat changed code defaults *again* (third time): - `make-structures`/`make-caves` and the islet knobs on the test server were - still the old values. Live config updated by hand. -- 164 tests green. - -## All player-facing text moved into the locale (2026-07-31) - -Ben's correction: never do MiniMessage conversion by hand - BentoBox's User -API is locale-aware and does it (`getTranslationAsComponent`, -`sendMessage`). Auditing for that exposed a bigger problem of my own making: -dialog titles, buttons and tooltips, boss bar text, hologram labels, item -names and lore were all HARDCODED ENGLISH in code, so none of it could ever -be translated. -Everything now goes through the locale: ~50 new `tradewinds.ui.*`, -`tradewinds.hud.*`, `tradewinds.hologram.*` and `tradewinds.item.*` keys, -fetched with `user.getTranslationAsComponent(...)`. The charted action bar -uses the locale's `[actionbar]` marker so BentoBox delivers it, rather than -calling sendActionBar around a manually parsed string. -Audit is clean: no `Component.text("...")` literals and no MiniMessage -references remain in src/main. Gotcha: the no-variable -`getTranslationAsComponent(key)` call is ambiguous between the String... and -TagResolver... overloads - pass `new String[0]`. -Convention recorded in CLAUDE.md, including the [actionbar]/[title]/[sound] -markers, which also offer a tidier route for the trade sounds later. -154 tests green. - -Ben: MiniMessage is the standard from now on. en-US.yml converted from legacy -ampersand codes to closed MiniMessage tags, matching core's own locale style -(`text`). Also fixed ChartingListener, which wrapped a translated -string in `Component.text` for the action bar - that would have printed the -tags literally; it now deserializes with MiniMessage. Convention recorded in -CLAUDE.md: locale strings are MiniMessage, and any Component-taking sink -(action bars, titles, holograms) must deserialize rather than wrap. - -## The nautilus is a mount, and swimmers were spawning in the air (2026-07-31) - -Ben reported the "sea horror" encounter swimming harmlessly away - and he had -seen our encounter message, so it was ours. Two causes: -- **ZOMBIE_NAUTILUS is not a monster.** 26.2's `AbstractNautilus extends - Tameable, InventoryHolder, Vehicle` - it is a rideable, tameable sea mount - with an inventory, so it has no attack goals at all. Replaced with an - ELDER_GUARDIAN as the FRONTIER+ deep-water encounter (a flee-not-fight - threat), and a test now asserts every encounter mob implements - `org.bukkit.entity.Enemy`, so scenery can never be rostered as a threat - again. -- **Water mobs were spawning above the waterline** (seaHeight + 1.5), where a - swimmer flops instead of hunting. EncounterType gained a Habitat: swimmers - spawn 3 blocks under water, phantoms 14 above, boated crews on the surface. -Also added debug logging of encounter spawns (gated by `debug:`) so "was that -one of ours?" is answerable from the console. - -NOTE for post-MVP: a tameable sea mount with an inventory is a gift for this -game mode - nautilus as an alternative to boats, or a late-game hold. Worth -its own feature. - -## Varied ocean biomes (2026-07-31) - -The open sea was a single flat OCEAN everywhere. It now varies through the -five ocean biomes from a seeded temperature field: a new pure `Noise` helper -(smoothstep value noise over a 3000-block lattice, seeded like everything -else) drives `GalaxyEngine.oceanBiomeKeyAt`. -Ben's blending worry has a structural answer rather than a blending pass: -because the field is CONTINUOUS and the biomes are mapped in TEMPERATURE -ORDER (frozen, cold, ocean, lukewarm, warm), neighbouring water can only -differ by one step - warm can never border frozen. A test walks 80,000 blocks -asserting the index never jumps by more than one, and that a voyage crosses -at least three different seas. -The provider declares all ocean biomes in getBiomes; `world.vary-ocean-biomes` -turns it off. FROZEN islands keep their frozen-ocean approach ring, which now -sits naturally inside cold water where the field allows. -152 tests green. - -## Playtest fix: the harmless sea witch (2026-07-31) - -The witch adrift did nothing and died easily. Two causes: a mob riding a boat -cannot run its attack goals at all (so the boated encounters - SEA_WITCH and -PIRATE_CREW - were ornaments), and the target was set once at spawn with -nothing to re-assert it, so any mob that lost interest stayed lost. -EncounterService now runs a 2s aggression pass over encounter-tagged mobs -near players: re-target when the target is gone, and ABANDON SHIP (leaveVehicle) -when the quarry is within 30 blocks - wider than the 28-block spawn distance, -so crews disembark as soon as they sight you. Ben then reported the witch -DOES throw potions but they sail over his head into the sea, which is the -same root cause: a passenger cannot reposition, so her arc is fixed from a -drifting platform. Swimming, she closes and aims normally. The abandoned -boat is left floating - salvage for the victor. This also stiffens the swimming -encounters (drowned, nautilus, guardians), which previously forgot their -quarry after a short chase. - -## The port scan: charts spread from port to port (2026-07-31) - -Discovery was sighting-only, so a player who warped everywhere never learned -anything new and the warp dialog stayed at the starter cluster. Ben's fix: -opening the chart AT a trading island copies that port's harbour charts - -the nearest `chart.port-scan` (8, matching the warp dialog cap) islands are -charted free. Implemented as `PlayerDataManager.portScan(player)`, called -from the chart command and from ChartHolograms.show (so boarding a boat at a -port scans too). Requires being within the protection range - at the port, -not merely in its waters - so discovery still needs landfall, and each port -teaches its own neighbourhood, which spreads the map outward naturally as -players trade. - -## Recovery: the pouch soft-lock and the harbourmaster (2026-07-31) - -Ben died, respawned with under $250, and could not afford a pouch. Worse than -a price problem: with NO pouch there is no hold, and with no hold a player can -neither buy nor sell - the market moves goods through the hold on both sides - -so a dead player could not earn the money to buy the thing that lets them -earn. A hard soft-lock, and my fault for pricing the entry ticket as an -upgrade. -Two fixes: -- **Pouches $50 flat** (was 250). First tried escalating prices (50/150/450) - keyed off carried pouches; Ben immediately spotted the hole - drop a pouch, - buy at the base price, pick it back up. Any per-pouch escalation is - defeated the same way, so the price is flat and the max-bundles CAP does - the limiting; that is also what keeps expanders necessary. A test asserts - the starting balance always covers a pouch plus a boat, and another pins - the price flat regardless of how many are carried. -- **The harbourmaster's charity**: at any market, a sailor with no cargo - space, no boat and too little money to buy either can claim a free pouch - (and a hull if needed), on a config cooldown (15 min). Unabusable by - construction: charity goods are unstamped, so they cannot be sold for - money, and the button only appears while genuinely destitute. -149 tests green. - -## Playtest fixes: empty ocean, unopenable expanders (2026-07-31) - -Ben teleported to 10000,10000 and found nothing at all. Measured: nearest -trading island 7329 blocks, and ZERO wild islets in a 6000x6000 area. Cause: -islets shared the trading-island grid (5000-block cells, and only in cells -with no island), averaging ~9000 blocks apart. They now have their own finer -grid (galaxy.wild-islet-grid, 1200) with a clearance test against trading -islands, giving an islet within ~500-1700 blocks of anywhere (tested). - -Also, and worse: **Java Edition cannot open a shulker box from the -inventory**, so the "carried hold" I had just designed could not be filled or -emptied by hand at all - Ben found this trying to read the lore on stamped -goods. `ExpanderListener` now opens an expander on right-click (27-slot view, -written back into the item on close), refuses nesting, and never lets an -expander be placed as a block (it is cargo, not a chest to be robbed). - -Charting range is now config (`chart.sighting-range`, 1200 - slightly beyond -island waters) so islands chart as they are sighted rather than only when -entering their waters. 145 tests green. - -## Playtest fix: respawn in the treetops (2026-07-31) - -Ben died and respawned on top of a tree at the island centre. SpawnRespawnListener -was still using `getHighestBlockYAt(0, 0)` from the bare-islet days - the island -CENTRE, which on a real trading island is wooded ground. It now uses -`IslandsManager.getSpawnPoint(world)` (the plaza, and wherever an admin later -moves it with /twadmin setspawnpoint), falling back to the world spawn. -The spawn point itself moved 3 blocks off the plaza centre so players do not -materialise inside the bell that stands there. 144 tests green. - -## Pouches for sale, white expanders (2026-07-31) - -Completing the carried-hold progression: the shipwright now sells Trading -Pouches (economy.pouch-price, 250) up to max-bundles, refusing beyond the cap -(HoldService.pouchCount counts past the cap so the shop can tell). At default -prices a pouch is ~$3.90/item against the expander's ~$2.89/item - the early -rung is deliberately the worse deal, so expanders stay the goal. -Expanders are now WHITE_SHULKER_BOX so they never read as a vanilla purple -shulker; isExpander accepts any *SHULKER_BOX carrying the PDC key, so purple -ones bought by earlier builds keep working. StarterKit reuses -MarketService.pouchItem() so the starting pouch and shop pouches are -identical. 143 tests green. - -## The hold is carried, not moored (2026-07-31) - -Ben bought a chest boat and found the hold still capped at his single pouch: -a chest boat is unfillable as an inventory item, and a moored one could not -be opened either - `Flags.CHEST` (MEMBER by default) guards chest boat -inventories, so visitors could not open their own boat at a port. The whole -"chest boat = bigger hold" step therefore did not work in practice. -Redesigned per Ben: the hold is what the sailor CARRIES - - pouches (bundles, max 3) + cargo expanders wherever carried + the chest - boat's inventory while riding one. -Expanders are now delivered to the pack instead of requiring a chest boat, so -they can always be opened and filled; HoldService gained `expanders(player)` -(pack + boat) and its add/remove/count/freeSpace all route through it, with -expanders preferred for storage. Ports also now grant CHEST and SHULKER_BOX -at visitor rank so sailors can open their own cargo at a dock. -Principle 1 is intact: loose pocket items are still invisible to the market. -Spec 4 updated. 142 tests green. - -## Bug: no boat use at ANY trading island (2026-07-31) - -Third instance of the same class of bug, and the worst: `applyBandFlags` - -which runs on every trading island - never set BOAT at all; only the spawn -island got that allowance. BOAT defaults to MEMBER rank, so on every island -in the galaxy a visitor could moor but not re-board their own boat after -shopping. Fatal for a boat game, and invisible while testing as op. -The harbor allowances now live in applyBandFlags, so every port grants -visitors BOAT, HURT_MONSTERS, CRAFTING, DOOR and GATE (plus the existing -ITEM_DROP/ITEM_PICKUP); the spawn bootstrap no longer repeats them and just -adds its no-hostiles/no-explosions settings. `/twadmin reflag` pushes them to -islands registered before this build. - -## Bug: spawn island bootstrapped before islands loaded (2026-07-31) - -Boats were STILL denied at spawn after the rank-flag fix. The server log gave -it away: - - 17:19:19 [TradeWinds] Designated Spawn (FISHING) as the spawn island - 17:19:19 [BentoBox] Loading islands from database... - -`bootstrapSpawnIsland` ran in onEnable, but core loads islands from the -database AFTER addon enable (BentoBox.java: islandsManager.load() at ~line -234, addonsManager.allLoaded() at ~276). So the island cache was empty: every -startup created a brand-new island (the grid rejected the duplicates) and the -subsequent database load shadowed our in-memory changes - which is why the -island JSON showed name/protection/range from an earlier run but -`"spawn": false` and no BOAT/CRAFTING/HURT_MONSTERS. -Fix: run the bootstrap from `allLoaded()`, which core calls after the island -load. -Second trap found while fixing it: `Island.setSpawn(true)` calls -`setFlagsDefaults()`, wiping the flag map - so ALL flag work must happen -after designating spawn, not before (band flags were being erased). The -bootstrap now applies band flags and harbor allowances last, and is -idempotent. - -## Bug: rank flags were being silently dropped (2026-07-31) - -"No boat usage at spawn" - and the same cause behind every rank flag we have -ever set. BentoBox core: - - public void setFlag(Flag flag, int value, boolean doSubflags) { - if (flags.containsKey(flag.getID()) && flags.get(flag.getID()) != value) { - -`setFlag` is a NO-OP when the flag is not already a key in the island's map - -and islands created via `IslandsManager.createIsland` start with an empty map -(the live spawn island's JSON had `"flags": {}`). So BOAT, CRAFTING, -HURT_MONSTERS, HURT_VILLAGERS, ITEM_DROP and ITEM_PICKUP were all discarded, -leaving each flag at its default rank (MEMBER for BOAT) - i.e. visitors -locked out. `setSettingsFlag` uses put() directly, which is why PvP and -monster-spawn settings always worked and masked the problem. -Fix on our side: `GalaxyIslandRegistrar.setRanks(island, Map)` -copies the flag map, writes the ranks and calls `setFlags` (which replaces the -map and marks the island changed). All rank setting now goes through it. -Also: `register()` used to return a pre-existing island untouched, so islands -from earlier builds never got names or flags - it now adopts them (name if -blank, band flags always), and spawn adoption additionally fixes protection -(400) and range (1000) left over from the old small-island bootstrap. -NOTE FOR CORE: `Island.setFlag` arguably should put unconditionally, or -`createIsland` should call `setFlagsDefaults()`. Worth a BentoBox fix. -141 tests green. - -## Trade feedback sounds (2026-07-31) - -Dialogs blur and cover the chat box, so refusal messages went unseen: every -market outcome now carries audio - BLOCK_NOTE_BLOCK_PLING (bright, pitch 1.6) -on a successful sell/buy/expander purchase, BLOCK_ANVIL_LAND (dull) on every -refusal path (cannot afford, no hold space, unstamped goods, expander cap, -missing chest boat, cancelled TWTradeEvent). - -## Playtest fix: outfitter stores go to the pack (2026-07-31) - -Buying a fishing rod failed with "no hold space" despite free space: gear does -not stack to 64, and `HoldService.addToBundle` only accepts 64-stackables, so -with no chest boat the hold could never take it. Ben's call (right on both -counts): outfitter goods are consumables/equipment - deliver them to the -player INVENTORY and do not customs-stamp them. Unstamped also means the -outfitter shelf cannot be arbitraged between islands, which keeps it a -service rather than a commodity market. `buyToInventory` generalized to an -amount (hulls reuse it), outfitter got its own dialog page offering x1 for -unstackables and x1/x16 for stackables, and the expander failure message now -names the real cause ("buy a chest boat first"). 140 tests green. - -## Stage 5b — Spawn is a real trading island (2026-07-31) - -Ben: make spawn a proper named island with dock, plaza and a config-picked -economy, designated as BentoBox's spawn - and preferably by reusing an -existing island rather than special-casing 0,0. Done by reserving the ORIGIN -CELL for it, which deleted more code than it added: the galaxy engine returns -a full SAFE trading island named "Spawn" centered exactly at 0,0 (economy from -galaxy.spawn-island-type, default FISHING, RANDOM allowed), so dock, plaza, -market, villagers, boss bar and border warp zone all come for free from the -existing systems. Other cells' islands are pushed radially clear of the origin -so min separation still holds (tested). The bare spawn islet and its -protection-range config are gone. -`bootstrapSpawnIsland` now: computes the plaza position from pure geometry (no -chunk load needed), sets it as the world spawn, registers the island via a -new reusable `GalaxyIslandRegistrar.register(spec, world)`, calls -`setSpawnPoint` + `IslandsManager.setSpawn` once (so admins can rename, move -the spawn point and change flags afterwards with normal BentoBox commands), -and re-asserts the harbor allowances every enable. -Players now spawn on a working market plaza with coal, money and a boat - -trade immediately, then walk to the dock and sail. No long empty row to start. -140 tests green (three tests had assumed 0,0 was open ocean). - -## Stage 5 — Risk at sea (2026-07-30) — CODE COMPLETE, awaiting in-game test - -Built as one package because it IS one idea: spec principle 2 (risk symmetry) -was half-implemented - warping was to risk the interstice, rowing to risk the -lawless ocean, but rowing was free AND safe, quietly dominant for the patient. - -**Interstice (warp risk):** `IntersticeService` rolls -travel.warp.failure-chance at jump time; failure teleports player+boat to the -NETHER world partway along the route (35-65%), spawns -interstice.ghasts-min..max Ghasts targeting them, fires TWWarpFailedEvent. -The owed destination is remembered and the free re-engage dialog is offered -every interstice.prompt-seconds; taking it calls the new -`WarpService.deliver()` (the arrival half, split out of the jump) with no -fuel charge. Fallback if the pending destination is lost (relog): a free jump -to the nearest charted island - stranding must be impossible. - -**Sea encounters (rowing risk):** pure `EncounterTable` (chance scales with -distance from the nearest island, clamped 0.25-1.0 of the band's base; -roster filtered by band and day/night) + `EncounterService` (45s rolls for -players actually at sea, one encounter at a time, spawned -encounters.distance ahead so fleeing is always possible, PDC-tagged, -setRemoveWhenFarAway). Roster: guardian picket (day), trident drowned -(night), ZOMBIE_NAUTILUS (26.2's new undead sea mob, FRONTIER+), phantoms, -pillager pirate crews and a sea witch in their own boats (LAWLESS+). -`EncounterListener` drops **customs-stamped** booty - so fighting is the -third income beside honest margins and smuggled sugar, and the stamp system -lets us aim the faucet precisely. - -138 tests green. - -## Teleport friction: stand-still before warping (2026-07-30) - -Ben asked about BentoBox's stand-still-before-teleport. Finding: /tw spawn -already extends DelayedTeleportCommand and calls delayCommand, so it is -wired - the server just has commands.delay.time: 0, and ops bypass delays -anyway (which is why it never appeared in testing). -The warp teleports from a dialog callback, out of reach of the command-level -delay, so WarpService gained an equivalent: travel.warp.stand-still-seconds -(default 0 = instant, preserving the spec's fast warp until Ben tunes it). -Moving >2 blocks during the countdown aborts the jump and SALVAGES the fuel -as charcoal into the hold (never a silent loss); ops and -tradewinds.mod.bypassdelays bypass. 133 tests green. - -## Spawn island visitor allowances (2026-07-30, playtest bug) - -Full protection was too much: visitors could not launch boats, defend -themselves or use a workbench at spawn. bootstrapSpawnIsland now (re-)asserts -policy every enable (existing spawn islands pick up changes): BOAT / -HURT_MONSTERS / CRAFTING at visitor rank; MONSTER_NATURAL_SPAWN, TNT_DAMAGE -and BLOCK_EXPLODE_DAMAGE settings off. (SAFE/POLICED trading islands already -had monster spawning off via bands config.) - -## Protected spawn island (2026-07-30) - -The protection flip left the spawn islet explicitly free-build (non-ops -could grief it). Fixed with Ben's own Stranger Realms machinery: TradeWinds -overrides isFixIslandCenter=false (arbitrary island centers - we had been -relying on luck that core's grid realignment never moved our jittered -trading islands) and isEnforceEqualRanges=false (arbitrary ranges - also -what Stage 7 claims will need). Then the spawn islet is a REAL BentoBox -spawn island: created unowned at the origin, protection -galaxy.spawn-protection-range (100), range shrunk to 200 (provably can -never overlap a starter island: min starter center is ~1250/axis > 200 + -1000). setSpawn + named "Spawn". Known wrinkle: the grid indexes the -creation-time range (1000) until first restart - on pathological seeds a -starter island rowed to in the very first session could fail to register -until restart heals the grid. hasDistanceMismatch honors the override, so -loads are safe. 128 tests green. - -## The Shipwright (2026-07-30) - -Boat acquisition (Ben's design): every market's Shipwright page sells Oak -Boat ($20) and Oak Chest Boat ($60) - explicit base prices above raw plank -cost (shipwright labor) so crafting from wild-islet timber stays the frugal -path - plus the cargo-expander ladder (moved from the main menu; the whole -cargo progression boat -> chest boat -> expanders now lives in one shop). -Hulls deliver to the player INVENTORY (a shipless sailor has no hold), via -MarketService.buyToInventory, stamped like any purchase. No free boat on -death: buy, craft, or /tw restart. Pitfall: first put the boat prices into -defaultFuelValues by matching the wrong map's tail - boats briefly counted -as 20 fuel units. 127 tests green. - -## The two economies: stamps, outfitters, wild islets, restart (2026-07-30) - -Ben's survival-design discussion (players died gearless and hungry; farming -must not mint money). Adopted into the spec as §5.0: -- **Customs stamp**: MarketService.stamp() on every purchase (PDC - tradewinds:stamp + lore, optional glint). sellableFilter() = stamped OR - configured unstamped exceptions (SUGAR) while illegal-trade is enabled. - HoldService went predicate-aware (count/remove/contents filter overloads - threading through shulkers and bundles). Different lore = stamped and - homemade stacks never merge. -- **Outfitter** (second buy page): bread always, charcoal when the trade - catalog lacks fuel (fuel guarantee moved here), per-type gear - (TypeEconomy.OUTFITTER_EXTRAS) - INDUSTRIAL arms you, farms sell beds, - fisheries rods, MINING a pickaxe. Gear prices derive from recipes (the - embedded engine pays off). Shelf capped at 8 (dialog fits, tested). -- **Wild islets**: empty galaxy cells roll small unnamed islands - (galaxy.wild-islet-chance 0.3, radius 70; pure engine math, deterministic, - never in a trading-island cell, tests). Vanilla wild biomes. All PROTECTION - flags default-allowed outside island protection ranges (blanket - setDefaultSetting loop) - open ocean and wild islets are free country; - future Stage 7 claim targets. -- **/tw restart** (ConfirmableCommand): zero balance -> fresh kit at spawn - (starter deposit restores starting balance), chart kept, restartsUsed - capped by player.max-restarts (3). -- Starting money note: already existed ($250 with the kit); Ben's account - predates Stage 4 so never saw it. -126 tests green. - -## The fuel guarantee (2026-07-30) - -Ben's rule: only the skint AND fuel-less row. `MarketService.saleCatalog` -wraps the type catalog and appends CHARCOAL when it carries no fuel-valued -material - so LUXURY (sells nothing) and AGRICULTURAL/FISHING/FROZEN all -offer charcoal, while FOREST (logs) and MINING (coal) already qualify. -Charcoal reclassified ORES -> WOOD (charred timber; forest islands sell it -cheap, differentiates from mined coal). Priced through the normal model. -Tested per type. 121 tests green. - -## Rower navigation: hologram compass + Star Chart (2026-07-30) - -Ben: /tw chart's text list was useless for navigation; rowers (no fuel) had -no idea which way to go. Two aids, both his design: -- **Hologram compass** (`ChartHolograms`): boated `/tw chart` spawns - TextDisplay holograms on a 10-block ring in each charted island's true - bearing (name/type+band/distance), zooming out from the player via display - teleport interpolation (setTeleportDuration). Shared-bearing islands stack - nearest-lowest (20-degree sectors). Caller-only (hideEntity for others), - non-persistent, auto-fade after chart.hologram-duration-seconds. Marker - geometry is pure and tested. `/tw chart list` keeps the text list. -- **Star Chart** (`StarChartRenderer` + `StarChartService` + `TWWorldData`): - /tw starchart gives a FILLED_MAP bound to one shared contextual MapView; - the renderer draws per holder - ocean background, holder centered as a - rotating cursor, charted islands as band-colored dots (sized by real - terrain radius) with MinecraftFont names, out-of-range islands pinned to - the map edge as heading hints. Redraw throttled to 1/s or a pixel of - movement. Map id persists in TWWorldData; MapInitializeEvent re-attaches - the renderer after restarts so old items keep working. Click-to-zoom is - not possible with map renderers - scale is config - (chart.starchart-blocks-per-pixel). -119 tests green. - -## Trade quantities + empty-hold UX (2026-07-30) - -Ben wanted partial sells ("left-click all, right-click one"). Dialog buttons -carry no click-type, so instead each cargo row is THREE grid buttons -(columns(3)): x1 | x16 | All-with-total; buy rows are x1 | x16 | x64. -MarketService.sell gained an amount cap (MAX_VALUE = all). The Sell button -hides when the hold has nothing the island pays for; sell rows cap at 8 -(dialog fits, note shown when truncated). 114 tests green. - -## Playtest fix: spawn islet + safe respawn (2026-07-30) - -Ben died and vanilla respawn hunted for "solid ground" near world spawn - -which in an ocean world is the seabed at y42, entombing him in a death loop. -Fix in three parts: -- **Spawn islet**: GalaxyConfig.spawnIsletRadius (config - galaxy.spawn-islet-radius, 48) - the engine's landLiftAt raises a small - plains islet at the origin (generator-made, principle 6; deterministic and - independent of galaxy islands, which stay bit-identical). needsReset: only - fresh chunks get it - delete the four origin region files on existing - worlds. -- **World spawn pinned** to the islet surface (getHighestBlockYAt at enable). -- **SpawnRespawnListener**: deaths in either TradeWinds world without a - bed/anchor respawn on the islet; bed and anchor spawns honored; other - gamemodes untouched. StarterKit's land branch now applies at first spawn - (boat item rather than water launch). -No BentoBox island object at spawn (a range-1000 island at the origin can -overlap a jittered starter island's range and break grid registration - see -the round-1 fix notes); protection of the islet itself is deferred. -114 tests green. - -## Accessibility tune: warp dialog capped at 8 (2026-07-30) - -The dialog scrolls beyond ~8 buttons, but the scroll affordance is easy to -miss (kids, accessibility). `travel.warp.max-destinations` default 20 -> 8 so -the list always fits on screen; live server config updated too (stored -values beat new defaults). Charting more islands is still fine - the eight -NEAREST are listed. - -## Stage 4b — Trade UX + embedded BlueBook pricing (2026-07-30) - -Feedback round on the market screens, plus a direction change from Ben: -- Every dialog page shows balance + approximate hold space - (`HoldService.freeSpace`: empty slots x 64 + stack headroom + bundle - weight); sub-pages get a Back exit button, the main menu a Close (the - dialog API's exitAction slot). -- **BlueBook's pricing logic is now embedded** (`economy.PriceEngine`, - ported from ~/git/bluebook PriceEngine) instead of referenced as a plugin: - config base table (renamed `economy.base-prices`) + recursive recipe - derivation (depth 6, cycle-safe, cooking adds COAL/8 fuel share, - stonecutting passthrough, output-count division), durability scaling and - enchantment weights included for later gear-selling. Reflection bridge and - BlueBook softdepend removed; cache invalidated on reload. Recipe source is - injectable - derivation is fully unit-tested (109 total green). - -## Stage 4 — Economy and cargo (2026-07-29) — CODE COMPLETE, awaiting in-game test - -The game becomes a game: buy low, sell high. - -- **Prices**: `PriceModel` (pure record, fully tested invariants: produce-> - demand routes profitable, same-island round trips always lose, margins - scale with band, stock drift clamps). Base prices from **BlueBook via - reflection** (soft dependency through AddonsManager - no compile-time - coupling; not in ~/.m2) with a ~50-entry fallback table in config. - `TradeCategory` classifies materials by name heuristics; `TypeEconomy` - holds the produce/demand tables and sale catalogs (code content for now, - config later). LUXURY produces nothing - pure demand sink in dangerous - space. -- **Stock drift**: `TWIslandData` (keyed by cell) persists per-category - stock; selling floods depress prices, buyouts raise them; lazy decay - toward equilibrium per hour on access. -- **The hold** (`HoldService`): chest-boat inventory + expander shulker - contents + up to `max-bundles` (3) bundles. contents/count/remove/add with - space handling. Pocket items are invisible to the market - tested. -- **Trade dialogs**: main (balance, sell/buy/shipwright) -> sell page (one - button per hold material with island prices) -> buy page (catalog in - 16-batches, limited by balance then by hold space - pay only for what - fit). Entry: right-click a resident trader (vanilla trade screen - suppressed) or `/tw trade` in protection range. `TWTradeEvent` - (cancellable) fires before every trade - Stage 6's contraband hook. -- **Cargo expanders**: PDC-marked gold-named shulker boxes, purchase-only - (no End -> no shells), price doubling per owned (`expander-base-price`, - `expander-cap`), stowed directly into the hold. -- **Vault**: BentoBox VaultHook; starter kit now deposits - `economy.starting-balance`. Needed the VaultAPI provided dependency in the - pom (VaultHook signatures reference EconomyResponse). -- Item drop/pickup explicitly set to visitor rank on trading islands (Ben's - request; also a Stage 6 jettison prerequisite). -- 100 tests green. - -## Playtest fix: creeper-proof markets (2026-07-29) - -Creepers could be lured into the plaza to blow up stalls/landmarks. Fixed with -core's own flags, asserted each enable for both worlds: -`CREEPER_DAMAGE=false` (explosions break no blocks, still hurt entities) + -`CREEPER_GRIEFING=true` - counterintuitive but required: GRIEFING=false makes -core cancel the ENTIRE explosion when the creeper targets a non-member, and -everyone is a non-member on unowned trading islands, so creepers could not -even hurt players. Residents were already creeper-proof (non-player damage -cancelled). setDefaultSetting persists into world.flags, overriding any stale -lazily-saved true from earlier runs. - -## Playtest tune: warp arrival distance (2026-07-29) - -350-from-center arrivals were a boring paddle and easy to get lost from even -with the HUD. Config reworked from margin-inside-border to a direct -`travel.warp.arrival-distance` (blocks from destination center), default 130 - -inside the default 10-chunk view distance, so you materialize looking at your -destination. Note 130 is just inside the terrain radius (160): a high-noise -flank can occasionally mean shallow shelf water on arrival; boats cope. Add a -water-nudge only if playtests show beached arrivals. - -## Playtest fix: warp offer at the visible border (2026-07-29) - -Ben rowed out of Tiabi, got "Now leaving", and no warp offer ever came. Root -cause: TWO borders. BentoBox's enter/leave messages and the Border addon's -wall are at the PROTECTION range (400); the warp trigger and arrival were at -the island-space range (~1000) - 550 blocks of dead ocean past the perceived -border. Fixed: the offer ring now straddles the protection edge (fires the -moment "Now leaving" appears; ring is trigger-distance wide each side so a -crossing cannot skip it), and warp arrival lands protection - margin (~350) -from the destination center - island in sight, short row to the dock. -Not-a-bug notes from the same session: the HUD keeps showing the island out -to the full range (island waters), the bar fill is closeness-to-dock -(1 - dist/range), and fuel never hides the warp dialog (greyed entries). - -## Stage 3b — Resident protection, band flags, nav bar, starter kit (2026-07-29) - -Playtest-driven package (traders died at night and scattered; no dock -navigation after warp; bare-handed spawn): - -- **Band flag policy** (`bands.*` config): per-band MONSTER_NATURAL_SPAWN - (SAFE/POLICED off - note: setting flags only govern the protection range, - so approaches stay dangerous), PVP, and HURT_VILLAGERS rank (SAFE=500 - prevents outright; elsewhere allowed - crime handled by Stage 6, not - invincibility). Applied at registration; `/twadmin reflag` re-applies to - existing islands after config changes. -- **Residents survive**: mobs never target them (EntityTargetLivingEntityEvent - cancel on the resident PDC tag); only player-caused damage lands; - `ResidentAuditTask` tethers strays home (HOME_KEY PDC, plain teleport) and - respawns killed residents after `residents.respawn-delay-minutes` using the - engine-deterministic staffing count (`GalaxyEngine.villagerCount`). - Rejected: wandering-trader night despawn (blocks night trading, more state). -- `/tw settings` restored - core targets the island AT the player's location, - so visitors get a read-only view for free (no rank = no toggling). -- **Navigation boss bar** (1s task): island name | standing | dock distance; - fills as you close on the pier; color per band. `TradeWinds.getPlayerStanding` is a - "Clean" stub until Stage 6. Config gate `hud.navigation-bossbar`. -- **Starter kit** (spec §4): first spawn arrival gives a named Trading Bundle - + oak boat - seated in it on water, item on land; `starterKitGiven` in - TWPlayerData. Boat owner UUID in PDC from day one. Repeat spawns auto-launch - a carried boat item. -- MockBukkit patch extended AGAIN for a 26.2 registry gap: - `keyed/damage_type.json` lacked `minecraft:sulfur_cube_hot` and - `org.bukkit.damage.DamageType` clinit died - added `damage_type` to the - KEYED diff; script synced back to GushBlock. (Third instance of this - pattern: any `No value for minecraft:X` in tests means another registry - file needs the diff treatment.) -- 84 tests green. - -## Stage 3 — Travel: warp, fuel, charting (2026-07-29) — CODE COMPLETE, awaiting in-game test - -**Verify-first resolved:** Paper 26.2 dialog API confirmed and used: -`Dialog.create(factory -> factory.empty().base(...).type(...))`, -`DialogType.multiAction(buttons)`, `ActionButton.create(label, tooltip, width, -DialogAction.customClick(callback, options))`, shown via Adventure's -`Player#showDialog`. No command round-trips - button callbacks call the warp -directly. - -**Done:** -- `RouteGraph` (pure): cost = Euclidean x fuel-per-block, ceil, min 1; - direction-independent `edgeKey("cx,cz>cx,cz")` overrides from config - (`travel.warp.edge-overrides`) make cheap lanes/expensive frontiers. - `arrivalPoint`: just inside the destination border on the origin bearing. -- `TWPlayerData` + `PlayerDataManager` (first Database use; AOneBlock - cache-in-front pattern): chart as Set of cell keys; new players pre-chart - the starter cluster; save on quit/disable. -- `ChartingListener`: chunk-crossing-gated moves chart islands whose range - (1000) the player enters - action bar + `IslandChartedEvent`. -- `FuelService`: hold = chest-boat inventory + bundle contents ONLY (spec - principle 1 - pocket fuel never counts). Cheapest-first consumption, - overshoot burned, lava bucket leaves its empty bucket. Values config - `travel.fuel-values`. -- `WarpService`: destination selection (charted-only, origin excluded, - nearest-first, capped, affordability-flagged - pure and tested separately - from dialog rendering); warp = TWWarpEvent (cancellable) -> consume -> - dismount -> teleportAsync player + boat -> re-seat next tick (AcidIsland - pattern) -> nausea/blindness/damage + portal effects -> - TWWarpCompletedEvent. Failure roll deliberately absent until Stage 5. -- `BorderPromptListener`: boat within trigger-distance (30) inside an island - border auto-offers the dialog, per-island cooldown. `/tw warp` (boated, in - island waters) and `/tw chart` commands. -- Tests: 75 green (route math incl. overrides + arrival geometry, chart data, - fuel hold-only/cheapest-first/lava rule, destination selection, border ring - geometry). - -**Notes:** -- Real `ItemStack`s work fine in tests under the mocked ItemFactory as long - as no ItemMeta operations are exercised (bundle internals are in-game-only - territory). - -## Stage 2 — Island content (2026-07-29) — CODE COMPLETE, awaiting in-game test - -Stage 0/1 checklists fully passed in-game before starting this. - -**Done:** -- **Deterministic dock** (open question resolved per the spec's lean): each - island has a seeded bearing; the quay is terraformed by the chunk generator - (principle 6 - generator makes land, nothing is pasted): stone-brick quay, - type-specific plank deck at sea+1, running from the plaza (45% of terrain - radius) out to open water (85%). Market **plaza**: flattened dirt-path disc - at sea+2 with a 10-block blend ring so it meets terrain without cliffs. - Geometry lives in the pure galaxy package (`DockPlan`, `ColumnPlan`, - `GalaxyEngine.columnPlanAt`); `GalaxyConfig` gained `seaLevel`. -- **IslandDecorator** (BlockPopulator, overworld only): when an island's - plaza-center chunk generates - bell landmark, four lantern posts, 2-4 stalls - (fence + type-colored wool canopy + barrel), all island-deterministic - (seeded from the island hash, not the chunk Random). Structures are - code-built; per-type blueprint sets can replace the stall builder later. -- **Residents** via `LimitedRegion.createEntity` → configure → `addEntity` - (Poseidon pattern): 3-5 villagers with professions per island economy - (`IslandPalette`), skin type per biome, persistent + PDC-tagged - `tradewinds:resident`; iron golems by band (SAFE 3 → ANARCHIC 0). Villagers - spawn on the plaza, inland of the quay (shoreline-safety rule). -- Tests: 55 green (dock-plan geometry, column plans, terraform materials, - decorator determinism/placement/residents, golem counts, villager types). - -**Pitfalls:** -- In test code, fully-qualified `world.bentobox...` names inside methods are - shadowed by CommonTestSetup's `protected World world` field - use imports. - -**Stage 2b — island identity (same day, playtest feedback "islands all look -the same; INDUSTRIAL doesn't look industrial"):** -- Type **landmarks** built by IslandDecorator on the inland plaza edge: - INDUSTRIAL brick chimney with campfire-on-hay signal smoke (visible from - sea), MINING shaft head + rails + ore, AGRICULTURAL wheat plot + hay, - FISHING smokehouse + moored OakBoat at the pier, FOREST log pile, LUXURY - quartz fountain, FROZEN ice beacon. -- **Plaza surface per type** (polished blackstone / smooth quartz / - cobblestone / podzol / planks / dirt path) via IslandPalette; type-colored - **banner + lantern at the pier end**; **workstation blocks** beside each - stall (safe now professions are XP-locked). -- **`galaxy.type-weights` config**: weights moved into GalaxyConfig (record - gained a typeWeights param with a compact-constructor fallback to enum - defaults on zero/empty totals; old 8-arg constructor kept for - compatibility). Settings parses name→weight map, unknown names logged. - Weight changes reshape the galaxy → needsReset. -- 58 tests green (landmark signatures, pier banner, weight overrides, - default-weights galaxy unchanged by the new parameter). - -**Playtest round 2 fixes (same day):** -- **Dock gap:** the plaza blend ring outranked the dock strip in - `columnPlanAt`, and on the seaward side the ring blends toward submerged - natural terrain - the quay started ~10 blocks offshore. Order is now plaza - disc → dock strip → blend ring, so the deck runs unbroken from plaza edge - to pier end. Regression test walks the full dock axis on 12 islands. -- **Villager professions collapsed to Fisherman/none:** vanilla villager - brains reset a zero-XP, no-job-site villager to unemployed, and stall - BARRELs are fisherman job sites, so the survivors all claimed those. - `setVillagerExperience(1)` at spawn locks the assigned profession. (Known - vanilla behavior - worth remembering for any future spawned-NPC work.) - -## Playtest round 1 fixes (2026-07-29) - -Ben's first in-game pass (see TESTING.md) found three failures; all fixed: - -1. **`/tw` → "There is no spawn in this gamemode".** Core `IslandSpawnCommand` - needs a spawn *island*, which nothing creates. Replaced with - `TWSpawnCommand` (DelayedTeleportCommand straight to the world spawn on the - sea surface; `World#setSpawnLocation(0, seaHeight+1, 0)` set in onEnable). - Deliberately did NOT create a BentoBox spawn island at origin: a - range-1000 island at 0,0 can overlap a starter island's range (worst-case - jittered center is ~1768 from origin) and would fail grid registration. -2. **`/tw create` gave free bedrock islands.** The default player command set - is wrong for TradeWinds - players buy islands at Stage 7. Player command - setup now registers only `spawn`, `info`, `language`. Lifecycle test locks - this in (create/reset must be absent). -3. **Nether portal reached the interstice.** `create-and-link-portals: false` - only stops BentoBox's own linking; Multiverse handled the portal anyway. - `IntersticePortalListener` now cancels PortalCreateEvent, PlayerPortalEvent - and EntityPortalEvent touching either TradeWinds world at LOWEST priority. -4. **"No islands found" was not a bug but a discoverability gap** - islands - register lazily on center-chunk load and nobody had traveled 3.5k blocks - out. Added `/twadmin islands` (nearest 10 via pure galaxy query, works - pre-generation) and `/twadmin tpisland ` (chunk-loading teleport). - -46 tests green after fixes. - -## Stage 1 — Seeded galaxy (2026-07-29) — CODE COMPLETE, awaiting in-game test - -**Done:** -- `world.bentobox.tradewinds.galaxy` package — pure Java, zero Bukkit imports, - fully headless-tested: `GalaxyEngine` (placement, types, security bands, - biome keys, names), `GalaxyConfig`, `IslandSpec`, `IslandType`, - `SecurityBand`, `NameGenerator` (classic Elite digraph table), `Hashing` - (SplitMix64 over (seed, cell, salt) — order-independent, no shared Random). -- **Placement is constructive, not rejection-sampled:** cells of side - 2×min-separation, jitter capped at min-separation/2 → any two islands are - ≥ min-separation apart by construction. Deterministic and O(1) per query. -- **Starter density floor:** the `starter-cluster-min-islands` cells nearest - spawn are force-occupied and SAFE regardless of density rolls. -- **Bands by distance from spawn** (`galaxy.band-radius` per step) with ±1 - seeded wobble — the map communicates risk radially. FROZEN islands freeze - their approach ring (terrain radius → 2×) into frozen_ocean ice lanes. -- Generator: `terrainLift` (additive, cosine mask, `galaxy.land-lift` at - center) replaces the Stage 0 multiplicative hook; island columns that clear - the sea get stone/dirt/grass soil so vanilla decoration plants them. -- `TradeWindsBiomeProvider` resolves galaxy biome keys via `Registry.BIOME` - with cache; declares every possible biome in `getBiomes()`. -- `GalaxyIslandRegistrar` (ChunkLoadEvent): registers unowned BentoBox islands - at center-chunk first load — `createIsland(loc, null)`, name set (BentoBox's - ENTER_EXIT_MESSAGES announces it), PVP flag per band, type/band in island - metadata. Guarded by an in-session cell set + `getIslandAt` DB check. -- `TradeWinds.getGalaxyEngine(worldSeed)`: lazy init; `galaxy.seed` 0 means - "use the world seed" (logged so admins can share it). -- Tests: 41 total green (19 new: engine determinism/order-independence/ - separation/starter-floor/bands/lift/biome-ring, name generator, registrar, - updated generator + biome provider tests). - -**Pitfalls:** -- `putMetaData` on a fresh Island silently no-ops until `setMetaData(new - HashMap<>())` initializes the map — the registrar does this explicitly. -- NameGenerator: the Elite digraph table's '.' skip tokens can roll a 1-2 - letter name; the fallback loops until ≥3 letters (a single fallback append - could itself be the 1-letter "A." pair). - -## Stage 0 — Addon scaffold (2026-07-29) — CODE COMPLETE, awaiting in-game test - -**Done:** -- Maven project (`pom.xml` cloned from GushBlock's verified 26.2 toolchain: - Paper API `26.2.build.40-alpha`, BentoBox 3.18.1, JDK 25 / release 21, - patched MockBukkit `4.113.4-p262`, surefire `--add-opens` set, JaCoCo). -- `TradeWinds extends GameModeAddon` (AcidIsland lifecycle pattern): - overworld `tradewinds_world` + interstice `tradewinds_world_nether` - (NETHER environment), **no End world ever** (spec principle 7). - `/tw` + `/twadmin` via Default{Player,Admin}Command; both default player - actions are `spawn` since TradeWinds players own no island at first. -- `ChunkGeneratorWorld`: ocean everywhere — bedrock, solid base, Perlin ocean - floor (8 octaves, scale 1/30, ±25 amplitude from `sea-floor` 25), water to - `sea-height` 70. Interstice identical shape with netherrack/basalt/soul-sand - palette. Vanilla noise/surface OFF so no vanilla continents can appear. - `terrainScale(worldInfo, x, z)` hook is where Stage 1's radial island mask - multiplies in. -- `TradeWindsBiomeProvider`: sea/air biome split (Poseidon pattern), interstice - biome for NETHER. Defaults OCEAN / OCEAN / NETHER_WASTES. -- `Settings` adapted from Gusher's (full WorldSettings), plus Stage 0 skeleton - knobs: `galaxy.*` (seed, min-separation, starter cluster), `travel.warp.*` - (fuel-per-block, failure-chance), `illegal-trade.enabled` master gate. -- Tests: 22, all green — Settings defaults, addon lifecycle incl. world - creation via ServerMock, generator shape/determinism/palette/terrainScale - hook, biome provider. - -**Pitfalls:** -- `org.bukkit.block.Biome` static init died in tests: 26.2 added - `minecraft:sulfur_caves` and the patched MockBukkit's `keyed/worldgen/biome.json` - lacked it. Fixed by adding `worldgen/biome` to the KEYED diff map in - `scripts/build_patched_mockbukkit.py` (note the registry file is under - `keyed/worldgen/`, not `keyed/`). Script re-run, artifact reinstalled, and the - updated script synced back to `~/git/GushBlock/scripts/` so a rerun there - cannot clobber the shared `~/.m2` artifact without the biome entries. -- Use `getServer().getWorld(...)` (not `Bukkit.getWorld`) in `createWorlds()` — - ServerMock backs it in tests, and `WorldCreator.createWorld()` resolves - through the mocked server, so world creation is fully unit-testable. - -**Interstice inaccessibility (Stage 0 accept):** `world.nether.create-and-link-portals` -is false so BentoBox never links portals into the interstice; there is no -command that goes there. Verify on the live server per `TESTING.md` — if some -other plugin (Multiverse) offers a route in, revisit with a listener at Stage 5. - -## Pre-Stage 0 (2026-07-29) - -- `TRADEWINDS_SPEC.md` written (consolidates overview/design-decisions/dev-plan; - spec wins on conflict). `CLAUDE.md` per GushBlock pattern. -- Reference intel gathered: AcidIsland (GameModeAddon anatomy, boat handling), - Poseidon (ocean noise), GushBlock (26.2 toolchain + MockBukkit patching), - BlueBook (`PriceEngine.getPrice(ItemStack, worldName)`), Border (visual - border, `BorderShower` API, `barrier-offset`/passable behavior). diff --git a/docs/TESTING-archive.md b/docs/TESTING-archive.md deleted file mode 100644 index 25a6576..0000000 --- a/docs/TESTING-archive.md +++ /dev/null @@ -1,1301 +0,0 @@ -# TradeWinds — Manual Test Archive (superseded) - -*Kept for the record only. This is the chronological, stage-by-stage checklist -as it stood on 2026-08-02, before it was reorganised by risk into `TESTING.md`. -Much of it tests mechanics that no longer exist - trading pouches, customs -stamps, carried-shulker expanders, the chest boat as a hold - and the notes -under ticked items record bugs already fixed. Use `TESTING.md` to test; use -this to remember what was checked and why.* - -# TradeWinds — Manual In-Game Test Checklists - -Per-stage manual verification on the test server (`/Users/ben/Minecraft/26.2`). -Deploy: `cp target/TradeWinds-*-LOCAL.jar /Users/ben/Minecraft/26.2/plugins/BentoBox/addons/` -then restart the server. Automated coverage lives in `src/test`; this file is for -what only a live server can prove. - -## Stage 0 — Addon scaffold - -- [x] Server starts with no errors/warnings from TradeWinds in the console. -- [x] `bbox version` lists TradeWinds alongside the other gamemodes (AcidIsland, AOneBlock, Gusher…), state ENABLED. -- [x] Worlds `tradewinds_world` and `tradewinds_world_nether` exist (`mv list` / console log). -- [x] `/tw` (and `/tradewinds`) teleports the player to spawn in open ocean — water to the horizon, no land, no vanilla continents. - - ~~Fail - Running `/tw` reports the error "There is no spawn in this gamemode" and nothing happens.~~ - - ~~Running `/tw create` makes an island for the player with a bedrock.~~ - - FIXED (retest): `/tw spawn` is now a TradeWinds command that teleports straight to the - world spawn on the sea surface (no spawn island needed); `create`/`reset` are removed — - player islands are purchase-only (Stage 7). Also retest: `/tw create` must now be - an unknown command. Delete the stray bedrock island from the earlier test with - `/twadmin delete `. -- [x] Ocean floor exists (dive down: sand/sandstone floor roughly y25–y50, bedrock at bottom, no caves by default). -- [x] Above-water world is air up to build height (no floating junk). -- [x] The interstice is inaccessible: nether portals in `tradewinds_world` do not activate/link (build one and light it), and no command teleports there. - - ~~FAIL: I built a portal and was able to port there.~~ - - FIXED (retest): a portal listener now cancels portal creation AND portal teleports in - both TradeWinds worlds (the config flag only stopped BentoBox's own linking, not - Multiverse's). Retest: lighting an obsidian frame should do nothing at all; the - portal built during the earlier test should also no longer teleport. -- [x] Interstice world (teleport there as admin, e.g. `mv tp`): water sea over basalt/soul-sand floor, nether ambience. -- [x] No `tradewinds_world_the_end` world is created. -- [x] `/twadmin` responds (admin help). -- [x] `addons/TradeWinds/config.yml` generated with all Stage 0 sections (galaxy, travel, illegal-trade, world). -- [x] Restart the server: worlds reload, no duplicate-world or generator errors, chunks unchanged (fly the same area). -- [x] Other gamemodes still work (create/visit an AcidIsland island). - -## Stage 1 — Seeded galaxy - -Set `galaxy.seed` in `addons/TradeWinds/config.yml` to a known value (e.g. `20260729`) -and delete the `tradewinds_world*` folders for a clean generation, then: - -- [x] Console logs `TradeWinds galaxy seed: ` on first world access. -- [x] Use `/twadmin islands` to list the 10 nearest trading islands (works before any - terrain generates — it queries the galaxy engine), then `/twadmin tpisland 1` to - visit the nearest. The registration log line (name, type, band, coords) appears - when the island's center chunk loads, i.e. on arrival. - - ~~FAIL - no islands logged in console. Cannot TP to any islands either.~~ - - Not a generation bug: islands register lazily when their center chunk first loads, - and nothing had generated chunks 3.5k+ blocks out. The missing piece was a discovery - tool — hence the two new admin commands above. -- [x] Islands rise smoothly from the ocean: underwater shelf → beach → grassy interior; no cliffs of floating terrain, no chunk-border seams, no pop-in (land is generated, not pasted). -- [x] Each island has a single whole-island biome matching its logged type (e.g. MINING → windswept hills; FROZEN → snowy, with **ice sheets in the surrounding water ring** — ride a boat over the ice: it should be fast). -- [x] Entering an island's protection range announces its name ("Now entering "). -- [x] `bbox` island info at an island (`/twadmin info` while standing there) shows an unowned island, range 1000, protection 400. -- [x] Restart the server: the same islands are still registered (no duplicate-registration log lines), names unchanged. -- [x] Regenerate the world from scratch with the same seed (stop server, delete world folders AND `database/` TradeWinds islands): identical island positions, names, types. -- [x] Islands are never within sight of one another (min separation 2500). -- [x] PvP setting: on a LAWLESS/ANARCHIC island the island PVP flag is on; on SAFE it is off. - -## Stage 2 — Island content - -**IMPORTANT: needs freshly generated island chunks.** Docks/plazas/villagers only appear -in chunks generated by this build — visit islands you have NOT been to before -(`/twadmin islands`, pick ones beyond previous exploration), or regenerate the world. - -- [x] Every island visited has a **dock**: a straight stone-brick quay with a plank deck - one block above the water, running from the island's flank out into open water. - A boat can pull up alongside the deck end. - - ~~see screenshot at /Users/ben/Downloads/2026-07-29_16.40.38.png. The dock always has a gap between it and the land. It'd be better to have it join the land, otherwise it's not going to be used much.~~ - - FIXED (retest, needs fresh chunks): the plaza's blend ring took precedence over the - dock strip, so the ring blended down to submerged terrain on the seaward side and - cut the quay off ~10 blocks from shore. The dock strip now wins over the ring: the - deck runs unbroken from the plaza edge to the pier end (one step down from plaza - to deck). A regression test walks the full dock axis on 12 islands. -- [x] The dock's plank type varies by island type (oak/spruce/acacia/cherry/dark oak). -- [x] Inland of the dock: a flattened **market plaza** (dirt-path disc) with a bell at - the center, four lantern posts, and 2–4 market stalls (fence posts + colored wool - canopy + barrel). Stall canopy color matches the island type. -- [x] Plaza edges blend into the terrain — no sheer walls around the plaza disc. -- [x] **Villagers** (3–5) stand on the plaza, professions matching the island's economy - (FISHING → fishermen; AGRICULTURAL → farmer/butcher/shepherd; MINING → mason/toolsmith; - INDUSTRIAL → smiths; LUXURY → librarian/cleric; …). Skin variant matches the biome - (snowy islands → snow villagers, desert → desert, …). - - ~~FAIL - they are always Fishermen, or no profession.~~ - - FIXED (retest, needs fresh chunks): vanilla resets a zero-XP villager with no - claimed job site to unemployed — and the stall barrels are fisherman job sites, - so the rest converged on Fisherman. Spawned villagers now carry 1 trade XP, - which locks the assigned profession permanently. Existing villagers from the - previous build will stay broken; only newly generated islands count. -- [x] Villagers are on the plaza, NOT at the waterline (shoreline-safety rule). -- [x] **Iron golems** on the plaza: 3 on SAFE, 2 on POLICED, 1 on FRONTIER/LAWLESS, - none on ANARCHIC. -- [x] Leave (unload chunks) and return: villagers and golems are still there (persistent, - no despawn). -- [x] Restart the server and revisit: residents persist, and no duplicate set spawns. -- [x] Same seed regeneration: dock bearing, plaza position, and stall layout are identical. - -## Stage 2b — Island identity (type landmarks, dressing, configurable weights) - -Playtest feedback: islands looked the same apart from biome; INDUSTRIAL didn't read -industrial. **Needs freshly generated island chunks**, as ever. - -- [x] Each island has a **type landmark** on the inland edge of the plaza (opposite the dock): - - INDUSTRIAL: brick chimney with a smoking signal fire on top (visible from the sea!), - blast furnaces, anvil, coal/iron block piles at the foot. - - MINING: timbered shaft head, rails, spoil heap, exposed ore blocks. - - AGRICULTURAL: fenced wheat plot with irrigation channel, hay-bale stack. - - FISHING: smokehouse campfire + barrel stack, and a moored rowboat at the pier end. - - FOREST: log pile with stripped logs. - - LUXURY: chiseled-quartz fountain with potted flowers. - - FROZEN: packed-ice beacon cairn with a lantern. -- [x] **Plaza surface** varies by type: polished blackstone (INDUSTRIAL), smooth quartz - (LUXURY), cobblestone (MINING), podzol (FOREST), planks (FISHING/FROZEN), - dirt path (AGRICULTURAL). -- [x] A **type-colored banner** and lantern post fly at the seaward end of every quay — - the island's "flag" as you approach by boat. -- [x] **Workstations** beside each stall (blast furnace/grindstone/anvil on INDUSTRIAL, - lectern/brewing stand on LUXURY, composter/smoker/loom on AGRICULTURAL, …). -- [x] Villagers do NOT lose or change their professions near the workstations (locked by XP). -- [x] `galaxy.type-weights` appears in config.yml; setting a type's weight to 0 and - regenerating removes that type (needs world regen — weights are seed-shaping). - -## Stage 3 — Travel: warp, fuel, charting - -Get a chest boat, put fuel in it (coal is 8 units, a coal block 80, logs 1, -a lava bucket 100 — see `travel.fuel-values`), and: - -- [ ] **Charting**: joining fresh, `/tw chart` already lists the starter-cluster islands - (pre-charted). Rowing into a NEW island's waters (range 1000) pops "Charted !" - on the action bar, and it appears in `/tw chart` with type/band/distance. -- [ ] **Warp offer**: rowing a boat across an island's VISIBLE border (the protection - edge, ~400 from center — the moment "Now leaving " appears) pops the warp - dialog automatically (once per 30s per island). `/tw warp` opens it anywhere - inside an island's waters while boated. - - ~~Playtest: no offer when rowing out past the leaving message~~ FIXED: the - trigger sat at the far range edge (~1000), 550 blocks of empty ocean later. - Now it fires right at the visible border. (Fuel never gates the offer — - unaffordable routes show greyed out.) -- [ ] The dialog lists **charted islands only** (uncharted never appear), nearest first, - with fuel cost per destination; the body shows fuel aboard. Unaffordable entries - are dark grey and clicking them just says "not enough fuel". -- [ ] **Warp**: clicking an affordable destination consumes fuel from the chest boat / - bundles (check the inventory after), plays portal particles/sound, dismounts, - teleports player AND boat, re-seats the player in the boat, applies ~8s nausea + - 3s blindness + 1 heart damage, and lands **~130 blocks from the destination's - center on the side facing the origin island** — the island is right in front of - you at default view distance (`travel.warp.arrival-distance`). - - Playtest: 350 was too far — a boring paddle, and easy to get lost even with - the HUD. Retuned to 130 per test. -- [ ] Fuel in the player's pockets (not in chest boat or bundle) does NOT count and is - never consumed — hold-only is the spec's core rule. -- [ ] Lava bucket burns to an empty bucket (bucket stays aboard). -- [ ] Fuel costs match distance × 0.01 rounded up (check two islands at a known distance - via `/twadmin islands`); add an entry under `travel.warp.edge-overrides` - (e.g. `"0,0>0,1": 1`), reload, and see that edge cost change. -- [ ] Warp back: the full row-out → warp → warp-back loop works, including re-seating. -- [ ] Chart persists across relog and server restart. -- [ ] A player without a boat: `/tw warp` refuses ("aboard a boat"); the border prompt - does not fire while swimming. - -## Stage 3b — Resident protection, band flags, navigation bar, starter kit - -Playtest feedback round: traders died to night mobs and scattered; no way to find the -dock after warping; spawn dropped you in open water with nothing. - -**Resident protection (works on EXISTING islands too — no fresh chunks needed for -the listener/tether; respawn accounting also applies everywhere):** -- [ ] Stand on a FRONTIER+ island at night: hostile mobs spawn but never chase or hit - villagers/golems; a zombie walks straight past the traders. -- [ ] On SAFE/POLICED islands: no hostile mobs spawn inside the protection range at - all (outside the 400 radius and in open ocean they still do). -- [ ] Hit a villager to make it flee: within ~30s the tether teleports it back to - the plaza. -- [ ] Kill a villager (on a non-SAFE island — on SAFE the hit is blocked outright by - the HURT_VILLAGERS flag): after ~10 minutes with the plaza loaded, a replacement - spawns at the plaza with the right profession (console logs the respawn). -- [ ] `/tw settings` while standing on a trading island: any player can VIEW the flags; - toggling is refused (unowned island, no rank). `/twadmin settings` edits work. -- [ ] Change a `bands.*` config value, `/twadmin reload` (or restart), `/twadmin reflag`: - existing islands pick up the new flags. - -**Navigation boss bar:** -- [ ] Entering any island's waters shows a boss bar: island name | Clean | Dock m. -- [ ] The distance falls as you boat toward the dock and the bar fills; it updates - about once a second. -- [ ] Bar color tracks the band: blue SAFE, green POLICED, yellow FRONTIER, red - LAWLESS, purple ANARCHIC. -- [ ] Leaving island waters (or the world) removes the bar. `hud.navigation-bossbar: - false` disables it entirely. - -**Creeper griefing (playtest feedback):** -- [ ] Lure a creeper next to a market stall / landmark and let it explode: **no blocks - break** anywhere in the world, but the blast still damages you (and would damage - other players). Residents take no damage (mob damage is blocked for them). -- [ ] `world.flags` in config.yml shows `CREEPER_DAMAGE: false`, `CREEPER_GRIEFING: true` - after a restart (the addon re-asserts these each enable). - -**Starter kit:** -- [ ] A brand-new player's first `/tw` arrival: gets a gold-named "Trading Bundle", - and — since spawn is water — an oak boat appears with them seated in it. - Kit message shows once, ever (relog + `/tw spawn` again: no second kit). -- [ ] The starter boat has the owner recorded (no visible check yet — Stage 4+ uses it). -- [ ] Returning to spawn later WITH a boat item in inventory: it is placed and you are - seated (item consumed). Without one: you swim. - -## Stage 4 — Economy and cargo - -Vault + an economy plugin must be running (they are on the test server). Base prices -come from the embedded price engine: the config table (`economy.base-prices`) plus -recipe derivation — e.g. HAY_BLOCK derives from 9x wheat even if unlisted, and a -crafted item made of priced parts is automatically sellable. - -- [ ] New player: starter kit now also deposits the starting balance ($250). -- [ ] **Open the market**: right-click any resident villager, or `/tw trade` anywhere - within an island's protection range. Main menu shows balance, island type/band, - and Sell / Buy / Shipwright buttons. -- [ ] **Hold-only is enforced**: with wheat in your POCKETS and none in the hold, the - sell page says there is nothing to sell. Move it to the chest boat (or a bundle, - max 3 count) and it appears. This is the single most important economy check. -- [ ] **Buy low**: at an AGRICULTURAL island, crops are cheap (produce factor). Buy 16 - wheat — money leaves Vault balance, wheat lands in the chest boat. -- [ ] **Sell high**: haul it to an island that demands CROPS (FISHING) — the sell price - there beats what you paid. A same-island buy-then-sell always loses money. -- [ ] **Margins scale with danger**: the same demanded good pays visibly more at a - FRONTIER/LAWLESS island than at a SAFE one (band demand bonus). -- [ ] **Stock drift**: sell a large amount of one category at one island — its prices - for that category drop. Buy an island out — prices rise. Restart the server: - the drift persists (TWIslandData in the database). -- [ ] **LUXURY islands** sell nothing (no Buy button) but pay handsomely for gems, - luxuries, and fish. -- [ ] **Shipwright**: buy a Cargo Expander ($5000) — a gold-named shulker box placed - into your chest boat. The next one costs $10,000; the cap (4) refuses further - purchases. Goods inside the expander count for selling and fuel. -- [ ] Expanders cannot be crafted (no shulker shells exist — no End). -- [ ] A 4th bundle in your inventory does NOT add hold space (max-bundles 3). -- [ ] Without a chest boat and bundles, buying refuses with "no room in your hold". - -### Stage 4b — trade screen UX + embedded pricing (feedback round) -- [ ] Every market screen shows "Balance: $X | Hold space: ~N items", updating after - each transaction. -- [ ] Sell and Buy pages have a "< Back" button returning to the main menu; the main - menu has "Close". -- [ ] Recipe-derived pricing works: an item NOT in the base price table but craftable - from priced parts (e.g. a HAY_BLOCK, CRAFTING_TABLE from planks) gets a sane - sell price; junk with no priceable recipe (e.g. a stray BEDROCK) is untradeable. - -### Stage 4c — Spawn islet and safe respawn (playtest fix) - -**Existing worlds**: the islet only appears in freshly generated chunks. Stop the -server and delete the four region files around the origin -(`tradewinds_world/region/r.0.0.mca`, `r.-1.0.mca`, `r.0.-1.mca`, `r.-1.-1.mca`) — -everything else (islands, database) is untouched; the area regenerates with the islet. - -- [ ] A grassy plains islet (~96 blocks across) exists at 0,0; world spawn is on top - of it. -- [ ] `/tw` teleports onto the islet (dry land). First-time players get the bundle and - a boat ITEM (spawn is land now, not water). -- [ ] Die without a bed: you respawn ON the islet — not at 0,42,0 in the seabed, and - not drowning. Repeat deaths respawn there every time. -- [ ] Die with a bed set somewhere: the bed is honored. -- [ ] Dying in the interstice (admin-teleport there) without a bed also returns you to - the islet. - -### Stage 4d — trade quantities (feedback round) -- [ ] Main menu: with an empty hold (or nothing this island pays for), there is NO - "Sell cargo" button; it appears once sellable cargo is aboard. -- [ ] Sell page: each cargo type is one ROW of three buttons — "x1", "x16", - "All - $total" — selling exactly that many (x16 with only 9 aboard sells 9). -- [ ] Buy page: each catalog good is a row of "x1 / x16 / x64" at quoted totals, - still limited by balance and hold space. -- [ ] More than 8 cargo types: the sell page shows the first 8 with a note; selling - some reveals the rest. - -## Stage 4e — Rower navigation: chart holograms + Star Chart - -- [ ] **Hologram compass**: in a boat, `/tw chart` raises floating name-tags around you, - each hanging in the true direction of a charted island (name / type, band / - distance). They start at you and glide out to a ~10 block ring. -- [ ] Islands sharing a bearing stack vertically — nearest lowest, further ones above. -- [ ] Holograms fade after ~15s (`chart.hologram-duration-seconds`); re-running the - command replaces them; only YOU see them (check with a second account). -- [ ] Ashore (not in a boat) or with `/tw chart list`: the text list as before. -- [ ] **Star Chart**: `/tw starchart` gives a map item. Holding it: dark-blue ocean, - you centered as a rotating arrow, charted islands as band-colored dots with - names. Islands beyond the map edge are pinned AT the edge with their name — - a heading hint until you get closer. -- [ ] The chart follows you as you row (you stay centered); scale via - `chart.starchart-blocks-per-pixel` (64 = ~8km across). -- [ ] Restart the server: an existing Star Chart item still renders (the view id - persists and the renderer re-attaches). - -### Stage 4f — the fuel guarantee -- [ ] Every island's Buy page includes at least one fuel: FOREST sells logs (and - charcoal is classified as wood, so forest charcoal is cheap), MINING sells coal, - and everywhere else — including LUXURY, which otherwise sells nothing — CHARCOAL - appears as the fallback line, priced like any commodity. -- [ ] With money but no fuel, you can always refuel at whatever island you are at. - -## Stage 4g — Two economies: stamps, outfitters, wild islets, restart - -**World note**: wild islets appear only in freshly generated chunks (empty cells you -have not visited). The protection flip and stamps work immediately. - -- [ ] **Customs stamp**: goods bought from any market carry a lore line "⚓ Customs - Stamped" (PDC-marked; `economy.stamp-glint: true` adds a glint). Stamped and - identical unstamped items never stack together. -- [ ] Only stamped goods appear on the Sell page. Homegrown wheat/mined ore in the - hold: not offered, and "traders only buy customs-stamped goods" if forced. -- [ ] **Exception**: unstamped SUGAR sells fine (the smallholder economy) — unless - `illegal-trade.enabled: false`. -- [ ] **Outfitter** button at every market: bread always; charcoal where the trade - catalog has no fuel; INDUSTRIAL sells iron sword/shield/armor; farms sell beds; - FISHING sells rods; MINING an iron pickaxe; LUXURY golden apples. All priced by - the engine (gear prices derive from their recipes). - - ~~BUG: buying a fishing rod was refused for "no hold space" with 8 free~~ - FIXED: outfitter stores now go to your INVENTORY, not the hold, and are NOT - stamped (they are for using, not reselling - which also stops bread arbitrage). - Unstackable gear could never enter a bundle, which was the actual bug. - Retest: buy a rod with no chest boat - it lands in your pack. - - [ ] Non-stacking gear (rod, sword, bed) offers only x1; stackables also offer x16. - - [ ] Buying an expander without a chest boat now says "buy one from the - shipwright first" instead of a vague hold message. -- [ ] **Trade sounds** (the dialog blurs and covers chat, so outcomes are audible): - every successful buy/sell/expander purchase plays a bright pling; every refusal - (cannot afford, no hold space, unstamped goods, expander cap, no chest boat) - plays a dull anvil thud. -- [ ] **Wild islets**: row between trading islands through fresh ocean — small - unnamed islands (~140 across) in varied vanilla biomes, no dock/market/name - announcement. You can break/place/farm/sleep there and in open ocean - (protection flipped outside named islands). Trading islands remain protected. -- [ ] **/tw restart**: confirmation, then balance resets to starting, fresh kit at - spawn, chart KEPT; counter decrements (3 by default); refuses at 0. - -### Stage 4h — the Shipwright (boat acquisition) -- [ ] Every market's main menu has a "Shipwright" page: Oak Boat ($20-ish), Oak Chest - Boat ($60-ish), and the Cargo Expander ladder (moved here from the main menu). -- [ ] Buying a hull delivers the (stamped) boat item to your INVENTORY — place it at - the dock and ride off. Expanders still require a chest boat (hold delivery). -- [ ] Boats are NOT valid warp fuel (they are hulls, not firewood). -- [ ] Wild islets have trees: chop, plank, craft boat + chest → chest boat, the frugal - path. Bought hulls cost more than crafted ones by design. -- [ ] Boatless + broke: wild-islet timber or /tw restart are the exits. - -### Stage 4i — command permissions -- [ ] As a NON-OP player (test account, no LuckPerms grants): /tw, spawn, chart, - starchart, restart, info, settings, language all work. -- [ ] Non-op canNOT use /tw warp or /tw trade (op-only shortcuts) — but the intended - paths still work for them: rowing to an island border offers the warp dialog, - and right-clicking a plaza trader opens the market. -- [ ] Non-op cannot use /twadmin or its subcommands. - -### Stage 4j — protected spawn island (updated: visitor allowances) -- [ ] Console logs "Registered the spawn island (protection 100)" on first enable. -- [ ] As NON-OP: breaking/placing blocks on the spawn islet is blocked; wild islets - and open ocean remain free-build. -- [ ] /twadmin info at spawn shows an unowned island named "Spawn", protection 100, - range 200 (small ranges are legal now — Stranger Realms overrides). -- [ ] Restart: BentoBox loads cleanly (no "island distance mismatch" panic), spawn - island persists as spawn. -- [ ] As NON-OP on the spawn island: placing/riding a BOAT works, hitting a monster - works, using a CRAFTING TABLE works — but block break/place is still blocked. -- [ ] No hostile mobs spawn on the spawn island; primed TNT there breaks no blocks - (flags re-asserted every enable, so the existing spawn island picks these up). -- [ ] Trading islands still register/announce correctly (arbitrary centers untouched - by the isFixIslandCenter override). - -### Stage 4k — teleport friction (anti-escape) -Set `commands.delay.time: 3` in `plugins/BentoBox/config.yml` and test as a NON-OP -(ops and `tradewinds.mod.bypassdelays` holders always bypass delays): -- [ ] `/tw spawn` says "stand still for 3 seconds"; moving cancels the teleport. -- [ ] Set `travel.warp.stand-still-seconds: 3` in the TradeWinds config: engaging a - warp says "Hold your course..."; standing still completes the jump. -- [ ] Moving during the countdown aborts the warp and salvages the fuel back into the - hold as charcoal (dropped at your feet if the hold is full). -- [ ] With `stand-still-seconds: 0` (default) warps remain instant. - -## Stage 5 — Risk at sea - -**Half one: the interstice (warp risk).** Set `travel.warp.failure-chance: 1.0` -temporarily to force it. -- [ ] Warping drops you into `tradewinds_world_nether` partway along the route, in - your boat, with 1-3 Ghasts inbound and a "the warp collapses!" message. -- [ ] The re-engage dialog appears within ~20s (and repeats): clicking it delivers - you to the ORIGINAL destination for FREE — no second fuel charge. -- [ ] Stranding is impossible: relog in the interstice (losing the pending - destination) — the dialog still offers a free jump to your nearest charted - island. -- [ ] Ghast fireballs can be batted back; the fight is survivable in a boat. -- [ ] Restore `failure-chance: 0.05` afterwards. - -**Half two: sea encounters (rowing risk).** -- [ ] Row in open water far from islands: within a few rolls (45s each) mobs appear - ~28 blocks AHEAD of you — visible, so fleeing is a real choice. -- [ ] Day vs night differ: guardians by day, trident-throwing drowned at night. -- [ ] Lawless/anarchic water adds worse things: an ELDER GUARDIAN in deep water - (flee — it is meant to be unwinnable for a lone trader), phantoms at night, - pillager PIRATE CREWS in their own boat, and the sea witch. - - ~~The zombie nautilus just swam away~~ FIXED: it is a tameable MOUNT, not a - monster (AbstractNautilus extends Tameable/Vehicle), so it was never going to - attack. Replaced with the elder guardian; a test now asserts every encounter - mob implements Enemy. - - Water mobs also used to spawn ABOVE the waterline, where they flop instead of - hunting. They now spawn 3 blocks under, phantoms 14 above, boats on the - surface. - - ~~The witch adrift did nothing and was easy to kill~~ FIXED: mobs riding a - boat cannot run their attack goals, and the target was set once at spawn. - An aggression pass now re-asserts targets every 2s, and boated crews - ABANDON SHIP as soon as they sight you (~30 blocks, wider than the spawn - distance) — a mob that cannot reposition lobs its potions over your head. - The abandoned boat stays behind: salvage it if you want. -- [ ] Safe island waters are quiet (2% base, further reduced near the dock); - encounters intensify with distance from any island. -- [ ] Killing encounter mobs sometimes drops booty (nautilus shells, tridents, - ingots...) which IS customs-stamped — check it can be sold at a market. -- [ ] Only one encounter at a time per player; mobs despawn naturally when far away. -- [ ] `encounters.enabled: false` turns the whole system off. - -## Stage 5b — Spawn is a real trading island - -**World note**: the origin now hosts a full trading island instead of the bare islet. -Stop the server, delete the four origin region files (`tradewinds_world/region/r.0.0.mca`, -`r.-1.0.mca`, `r.0.-1.mca`, `r.-1.-1.mca`) AND the old spawn island from the TradeWinds -database (or use `/twadmin delete` on it), then restart. - -- [ ] Console logs "Designated Spawn (FISHING) as the spawn island" on first enable. -- [ ] `/tw` puts you ON the market plaza of an island named **Spawn** — villagers, - stalls, landmark, the lot. Trading is available from second one. -- [ ] Walk down the dock, place your boat, and sail: the border warp prompt appears at - the island edge like any other island. The navigation boss bar shows "Spawn". -- [ ] The island is SAFE band: no hostile spawns, no PvP, villagers protected — plus - the harbor allowances (boats, workbenches, hitting monsters) for visitors. -- [ ] Non-op still cannot break blocks there; TNT breaks nothing. -- [ ] Die without a bed: respawn on the Spawn plaza. -- [ ] `galaxy.spawn-island-type` picks the economy (FISHING default, RANDOM allowed) — - needs a world regen to change. -- [ ] Admin control works as normal BentoBox: `/twadmin setname`, `/twadmin - setspawnpoint`, `/twadmin settings` on the spawn island. -- [ ] No other island is closer than 2500 blocks to spawn (nothing crowds the port). - -### Stage 5c — island flags (three bugs: load order, setFlag, port allowances) -- [ ] As NON-OP at ANY trading island (not just spawn): moor, exit and RE-BOARD your - boat after shopping; hit a hostile mob; use a crafting table; open stall gates. - Run `/twadmin reflag` once to push these onto islands registered earlier. -- [ ] After restart, the island JSON in `plugins/BentoBox/database/Island/` shows - `"spawn": true`, `"name": "Spawn"`, and flags including BOAT/CRAFTING/ - HURT_MONSTERS at 0. -- [ ] Only ONE island file exists at the origin (no duplicate created per restart). -- [ ] Non-op at spawn: **place and ride a boat**, use a crafting table, hit a monster. - (Rank flags were silently dropped before — see PROGRESS.md.) -- [ ] Non-op anywhere: drop and pick up items on trading islands. -- [ ] `/tw settings` at spawn shows BOAT/CRAFTING/HURT_MONSTERS at Visitor rank. -- [ ] An island registered by an older build (no name, no flags) is adopted on - restart: it gains its name, band flags, and — for spawn — protection 400 / - range 1000. - -### Stage 5d — the carried hold, pouches and white expanders -- [ ] The Shipwright sells **Trading Pouches** ($250) up to the 3-pouch limit, then - refuses ("cannot carry any more"). A pouch is deliberately poorer value per item - than an expander — the early rung, not the destination. -- [ ] Cargo expanders are **white** shulker boxes named "Cargo Expander" (vanilla - purple ones from earlier builds still work as cargo). -- [ ] Buy a Cargo Expander: it lands in your PACK (no chest boat needed). Right-click - it anywhere to open and fill it - that is your hold. - - ~~Fail - right clicking a cargo expander did not open its inventory.~~ - - FIXED (retest): two gestures now open it. - 1. **Inventory screen** (the natural one, new): open your inventory, right-click - the expander where it lies (empty cursor) - the expander view opens. Left - click and click-with-item-on-cursor stay vanilla. An expander inside a chest - or another container does NOT open this way. - 2. **In hand**: hold it and right-click - at sea aiming at the sky AND ashore - aiming at the ground (air clicks used to arrive "already cancelled" and were - skipped). The shulker box must never be placed as a block either way. - Fill it, close it, reopen: contents persist. Sell from it at a market. -- [ ] Goods inside a carried expander are sellable, and count as fuel for warping. -- [ ] Hold space shown in the market reflects pouches + expanders (+ chest boat when - riding one). -- [ ] Riding a chest boat still adds its 27 slots to the hold; at a port you can - sneak-right-click your own chest boat to open it (CHEST flag now visitor rank). -- [ ] Loose items in your inventory are still NOT sellable (they must be in a pouch, - an expander, or the boat). - -### Stage 5e — respawn on the plaza (playtest fix) -- [ ] Die without a bed: you respawn ON the spawn island's market plaza, standing on - flat ground beside the stalls — not in the treetops at the island centre, and - not inside the bell. -- [ ] `/tw spawn` puts you in the same place. -- [ ] After `/twadmin setspawnpoint` somewhere else on the island, respawns and - `/tw spawn` both honour the new point. - -### Stage 5f — findable islets, openable expanders, charting range -- [ ] Row (or teleport) into open ocean far from any island: wild islets are now - within ~500-1700 blocks of anywhere — you should meet one within a short row. - They keep clear of trading islands. -- [ ] **Right-click a Cargo Expander in hand** to open it — this is the only way - (Java cannot open shulker boxes from the inventory). Put items in, close, and - they are still there when you reopen; they count as hold for trading and fuel. -- [ ] An expander cannot be placed as a block, and cannot be stowed inside another. -- [ ] Rowing within ~1200 blocks of an uncharted island charts it ("Charted !" - on the action bar) and it then appears in /tw chart, the warp dialog and the - star chart. `chart.sighting-range` tunes this. - -### Stage 5g — recovery: cheap first pouch and the harbourmaster's charity -- [ ] Trading Pouches cost a flat $50 each, up to the 3-pouch cap. (Escalating prices - were dropped: dropping a pouch before buying reset the count. The cap — not the - price — is what makes expanders worth buying.) -- [ ] **Soft-lock check**: with no pouch, no expander and under $50, the Shipwright - shows "Harbourmaster's charity": claim it for a free pouch (plus a boat if you - have none). You can immediately buy goods and trade again. -- [ ] The charity refuses if you are NOT destitute ("You'll manage"), and again - within 15 minutes of a claim ("Come back in N minutes"). -- [ ] Charity goods cannot be sold (unstamped), so repeat claims yield no money. - -### Stage 5h — the port scan (free chart at a trading island) -- [ ] Stand on any trading island and run `/tw chart` (or board a boat there): "You - copy the harbour charts: N islands added to yours." -- [ ] Those islands immediately appear in `/tw chart`, the hologram compass, the star - chart, and — the point of it — the warp dialog, so docking anywhere leaves you - with a full menu of onward routes. -- [ ] Running it again at the same port adds nothing (they are already charted). -- [ ] Opening the chart at sea (not at an island) does NOT scan — discovery still - requires making landfall. -- [ ] `chart.port-scan: 0` disables the free scan. - -### Stage 5i — varied ocean biomes -**Needs fresh chunks** (biomes are baked at generation). -- [ ] Sail a long way through open water: the sea changes through frozen, cold, - ordinary, lukewarm and warm ocean — water colour, fog and fish change with it. -- [ ] Transitions are gradual: you never find warm water directly against frozen. - (A test walks 80,000 blocks asserting the temperature never skips a step.) -- [ ] The same seed gives the same seas; a different seed gives different ones. -- [ ] `world.vary-ocean-biomes: false` returns the world to a single ocean biome. - -### Stage 5j — the sea itself: seabed, structures, islets -**Needs a fresh world** — terrain and biomes are baked at generation, so delete -`world/dimensions/minecraft/tradewinds_world` (and the nether one) before testing. -The test server's config has already been updated to the new defaults. - -**Islets — the "I couldn't find a single one" fix** -- [ ] Sail in any direction from anywhere: you meet an islet within a few hundred - blocks, not a few thousand. (Measured on the live seed: mean 544 blocks to the - nearest, worst case 1,376.) -- [ ] Islets vary — sandbars barely clearing the water through to islands 140 blocks - across, and small ones are lower as well as narrower. -- [ ] **Coastlines are not circles.** Islets and trading islands alike have bays, - headlands and lobes, and their land is hills and hollows rather than a smooth - dome with terraced contour rings. (`galaxy.coast-roughness` and - `galaxy.island-hilliness`; set both to 0 to see the old coin shape.) -- [ ] The shallow water around an island is not a perfectly circular pale ring — - the sea floor keeps rolling across the shelf. -- [ ] Islands are still whole: no island has broken up into a scatter of fragments, - and every trading island's quay still runs unbroken out into open water. -- [ ] Every islet has a sandy shoreline ring with beach biome (snowy beach in cold - seas), and grass or its own biome inland. -- [ ] Roughly 1 islet in 16 is **mushroom fields**: mycelium, mooshrooms, red and - brown mushrooms, and no hostile mobs spawning on it. - -**The sea floor** -- [ ] Dive in open water in several places: the floor is genuinely different depths — - sunlit banks around 14 blocks down, dark basins over 45. -- [ ] Find a **rift**: a narrow canyon wandering across the floor, dropping sharply - well below the surrounding seabed. -- [ ] Find a **seamount**: an underwater peak rising off a deep plain — and confirm it - never breaks the surface. Nothing but islands and islets should be land. -- [ ] The floor is patchy, not static: banks of sand, beds of gravel, clay pans, and - bare stone/tuff in the deeps — not alternating sand/sandstone every block. -- [ ] Water colour changes with depth as well as temperature: deep basins read as - deep ocean biomes (`F3` shows `deep_ocean`, `deep_cold_ocean`, ...). - -**What vanilla now puts there** (`make-structures: true`) -- [ ] **Shipwrecks** on the sea floor, and beached ones on islet shores. -- [ ] **Ocean ruins** — warm (sandstone) in warm/lukewarm water, cold (stone) in - colder water. They should match the water they are in. -- [ ] **Ocean monuments** in the deep basins, with guardians and elder guardians. - (Monuments only generate in deep ocean biomes, so this is the check that the - depth-to-biome mapping is working at all.) -- [ ] **Buried treasure** on islet beaches — a treasure map from a shipwreck or ruin - should lead somewhere real. -- [ ] **Trial chambers** in the rock under the sea floor: dig or cave down and find one. -- [ ] **No structure of any kind on a trading island** — no monument, ruin or village - through a plaza, dock or market. Fly around several islands and confirm. - (`world.keep-structures-off-islands: true`.) - -**Caves** (`make-caves: true`) -- [ ] **No voids in the sea floor.** Swim over a lot of open water: the floor is - unbroken. No dry craters, no open gashes, no ravines cut through to the water. - (Vanilla's carvers have no idea there is an ocean overhead and generated chunks - get no block updates, so nothing ever flows in to fill what they cut. The - generator seals a 5-block crust back over them in `generateCaves`, the Poseidon - fix — narrowed so the caves themselves survive.) -- [ ] Dig down through the sea floor anywhere promising: the caves are still there - underneath, and flood when you break into them. That is the point — sealed, not - filled in. -- [ ] Cave mouths in an island's flank *above* the waterline are left alone. -- [ ] Caves do not break into a market plaza or dock from below. - -**Regression** -- [ ] Trading islands still sit properly in the water, docks still run unbroken from - plaza to pier end, and the spawn island is unchanged — an island over a deep - basin must look the same as one over a shelf. - -## Stage 6a — The law: port flags and reputation - -**Flag policy changed for ALL trading islands.** Existing islands are re-flagged on -chunk load, but run `/twadmin reflag` after starting to be sure, then test as a -**non-op player** — every flag bug so far was invisible to an operator. - -### 6a-i — Port flags (the audit) -A trading island is now a public market: every protection flag is allowed at visitor -rank *except* an explicit deny list. -- [ ] **Things that must work** as a non-op visitor at any trading island: board and - leave a boat, open a chest boat and a cargo expander, use a crafting table, - anvil, furnace, grindstone, smithing table, loom, stonecutter, cartography - table and brewing stand, open doors/gates/trapdoors, press buttons, ring the - plaza bell, sleep in a bed, drop and pick up items, hit hostile mobs, fish, - throw an ender pearl. -- [ ] **Things that must NOT work**: break or place any block, use flint and steel, - prime TNT, empty a bucket, break an item frame or armour stand, edit a sign. -- [ ] **Things that must NOT work, because they would mint money outside the market** - (spec 5.0 — money enters the game only through trade margins): harvest or - trample the island's crops, open its barrels/containers/hoppers, take honey - from a hive, shear/milk/breed/kill its livestock. -- [ ] **Right-clicking a villager must NOT open a vanilla trade screen** — trading - goes through the market dialog and the hold only (principle 1). -- [ ] Hurting villagers is still refused on SAFE islands and allowed on rougher ones. -- [ ] `bands.port-denied-flags` / `port-allowed-flags` in config override both ways - (add `HARVEST` to allowed, reflag, and confirm you can now harvest). - -### 6a-ii — Reputation -- [ ] The navigation boss bar shows your standing, and it changes as you earn one. -- [ ] Punch a villager on a non-SAFE island: "The market saw that. Reputation -5." -- [ ] Kill a villager: bigger loss, and the message mentions money on your head. -- [ ] Kill an iron golem: reputation loss for killing police. -- [ ] Crossing a band boundary shows a title with your new standing. -- [ ] Commit enough crime to reach **Wanted** (−200 by default; one murder plus - change, or eight villager kills). Then **Fugitive** at −500. -- [ ] Reputation decays back toward zero by 1 point per 15 minutes of play — and - **only while online and in a TradeWinds world**. Log out for a while and - confirm your reputation is exactly where you left it. -- [ ] `/tw fine` out at sea is refused ("paid at a trading island"). At a port it - quotes a price, asks for confirmation, charges you, and returns you to Clean — - **never above it**. Check the balance actually moved. -- [ ] With nothing to answer for, `/tw fine` says so and charges nothing. -- [ ] As a Fugitive, `/tw fine` at a **SAFE** island is refused; at a rougher port it - works. (Crime pays, into danger — and buying your way back costs you the same.) -- [ ] `crime.enabled: false` stops all of it: no penalties, no messages, standing - reads Clean for everyone. - -### 6a-iii — PvP and bounties (partial; police land in 6c) -- [ ] Killing a player who is **not** a lawful target costs the killer 100 reputation - ("Murder"). -- [ ] But if the victim hit you first (within 30s), there is **no** penalty — the - anti-bait guard. Verify both ways round; a wanted player being able to farm - reputation loss off innocents by attacking them would be a weapon. -- [ ] Killing a **Wanted or Fugitive** player pays their bounty to the killer with no - penalty, and the bounty is cleared — kill them again and it pays **nothing**. - -## Stage 6b — Customs and contraband - -Contraband is **sugar** by default (`economy.unstamped-sellables`) — the one thing a -player can make and still sell, and the deliberate hole in the two-economies rule. -Grow some, or `/give` yourself sugar, and put it in your **hold** (a pouch, expander -or chest boat — loose pockets are not cargo and are not scanned). - -**Use `/twadmin customs`** — it prints the whole customs state where you stand -(contraband in your hold, your standing, this island's band, your effective scan -chance, patrol size, whether a chase is running). Scans are probabilistic and -cooldowns are invisible, so guessing from behaviour alone is a trap. - -### 6b-0 — Fixes from the first playtest -- [ ] The customs alert reads "CUSTOMS" with "Patrol dispatched from " beneath - it — **no line-feed glyph**. (A `` inside a title half is parsed into a - real newline character, which a title cannot render.) -- [ ] A chat line follows the title and stays in the log, so a blink does not lose it. -- [ ] **Log in inside a port with contraband: nothing happens.** Logging in is not an - entry. Row out and back in and the scan fires normally. -- [ ] **Warp away mid-chase, then into another island's space: the new island scans - you.** (Previously the old chase stayed live and blocked every later scan, so - customs silently stopped working for the rest of the session.) -- [ ] Standing on a plaza with contraband when a scan fires: there is no water for a - patrol to launch from, so customs simply seize the cargo and fine you, with a - message saying so — rather than spawning guardians that flop about on land. - -### 6b-0b — Fixes from the second playtest -- [ ] **Contraband actually pays.** Sell sugar at a FRONTIER/LAWLESS/ANARCHIC port: - roughly $12-14 an item, not $1. It pays more the rougher the port. - (`illegal-trade.contraband-price-multiplier`, default 8.0 — the main balance - lever, and the one price in the game not funded by a purchase elsewhere.) -- [ ] The sell page **no longer quotes a price for contraband at a port that will - refuse it** — sugar simply is not listed at SAFE/POLICED islands, instead of - being offered for a dollar and then declined at the counter. This is what made - `/twadmin customs` look like it was lying: it said "buys contraband: false" - while the dialog was showing an offer. -- [ ] **No trader stocks contraband.** Check the buy page at an AGRICULTURAL island: - no sugar. (If they sold it you could buy at the honest price and sell at the - black-market premium with no farming and no risk.) -- [ ] **A patrol never spawns on top of you.** It surfaces at least ~12 blocks away, - well beyond the 4-block arrest radius, so there is always a chase to run. - Previously the water search fell back to the player's own position: patrols - materialised alongside the boat, opened fire, and the arrest registered before - the warning had been read — "I got instantly hurt and lost the sugar". -- [ ] Note that warp arrival damage (`travel.warp.damage`, default 2.0) is separate - and intended — warping hurts. Do not confuse it with a patrol. - -### 6b-i — The scan -- [ ] Row or warp into a **SAFE** island's protection range carrying no contraband: - "Customs board you, look through the hold, and wave you on." (Set - `announce-clean-scans: false` to silence it.) -- [ ] Do it again immediately — no second scan, the per-island cooldown holds - (10 min default). This is what stops re-entry dice-rolling. -- [ ] Enter a **SAFE** island with sugar in the hold: title card "CUSTOMS — Patrol - dispatched", and drowned/guardians surface **between you and the island**. -- [ ] Warping in triggers the scan exactly like rowing in. -- [ ] Enter an **ANARCHIC** island with contraband: no scan at all, ever. -- [ ] Being **Upstanding** noticeably reduces how often you are searched; being an - Offender or worse increases it. - -### 6b-ii — The chase, and its three ways out -- [ ] **Jettison**: drop the sugar mid-chase. "Your cargo goes over the side" — the - patrol despawns, no fine, no reputation loss. The dropped items float and - **anyone else can pick them up** (this is where piracy comes from). -- [ ] **Run**: get more than 400 blocks beyond the protection range. "Open water..." - — you keep the cargo, no penalty. -- [ ] **Caught**: let a patrol unit reach you (within 4 blocks) or let one hit you. - All contraband is seized, a fine is taken, and reputation drops by 30. - Check the balance actually moved and the hold is actually empty of sugar. -- [ ] Fight the patrol instead: killing a unit costs reputation (KILL_POLICE)... -- [ ] ...and **police drop nothing at all** — no items, no XP. Kill several and - confirm. (Loot-bearing police would make a criminal record an iron farm.) -- [ ] The chase ends by itself after 2 minutes if nothing else resolves it, and the - patrol despawns. Log out mid-chase and the patrol is cleaned up too. - -### 6b-iii — The flee flag -- [ ] After escaping a chase, go back to **that same island** within 20 minutes: - "[name] remembers you... the patrol is already coming." No scan roll — the - patrol launches immediately. -- [ ] A *different* island still rolls normally. The flag is per-island. -- [ ] The flag expires after 20 minutes. - -### 6b-iv — Selling contraband -- [ ] Try to sell sugar at a **SAFE** or **POLICED** island: refused, with the trader - telling you to try a rougher port, and the dull "no" sound. -- [ ] Sell it at a **FRONTIER**, **LAWLESS** or **ANARCHIC** island: it sells, with no - customs stamp needed. This is the only way to turn farmed goods into money. -- [ ] `illegal-trade.safest-contraband-buyer: SAFE` makes every port buy it; - `ANARCHIC` makes it almost unsellable. -- [ ] `illegal-trade.enabled: false` removes all of it: no scans, no patrols, and - sugar becomes an ordinary unsellable homemade good. - -## Stage 6c — Police, wanted response, bounties - -Get yourself **Wanted** (−200: one murder, or ~8 villager kills) with -`/twadmin customs` to check your standing, then sail into a policed island's space. - -### 6c-i — The standing response -- [ ] Enter a **SAFE** or **POLICED** island's protection range while Wanted: "…has - patrols out for you", and units appear — guardians, drowned and a **phantom** - if you are afloat; **iron golems** and a phantom if you are ashore. -- [ ] The phantom is the only thing that can follow a boat. Row away and confirm it - keeps up while the swimmers fall behind. That is the point of the roster. -- [ ] Phantoms **do not burn at dawn**. Trigger a response in daylight and watch. -- [ ] A **FUGITIVE** draws a larger response than merely Wanted. -- [ ] **LAWLESS** sends one unit; **ANARCHIC** sends nobody at all. Confirm you can - sit in anarchic water as a fugitive completely unmolested — that is the whole - reason to go out there. -- [ ] Police ignore innocent bystanders: stand a second (clean) player next to the - patrol and confirm it stays on the wanted one. - -### 6c-ii — Break-off and no leaks -- [ ] Cross the border and keep going: past ~400 blocks beyond the protection range - the patrol breaks off ("The patrol turns back at the border") and the units - are **removed**, not left drifting. -- [ ] Pay your fine mid-pursuit (`/tw fine`): the patrol stands down immediately. -- [ ] Log out mid-pursuit, log back in: no orphaned police anywhere. -- [ ] Fly a long way off and come back: no accumulation of stray guardians or - phantoms. (`/twadmin customs` shows whether a chase is live.) -- [ ] Kill police: still **no drops, no XP**, whatever the unit type. - -### 6c-iii — PvP override and bounties -- [ ] A Wanted player can be attacked **even on a SAFE island** where the PvP flag is - off. A clean player in the same place still cannot be attacked. (Without this a - wanted player could moor in high security and be untouchable.) -- [ ] Killing them pays their bounty to the killer, with no reputation penalty. -- [ ] The bounty is cleared by the payout — kill them again and it pays **nothing**. -- [ ] Their name shows the bounty beside it while it is above zero, and the tag - disappears once paid out. (`crime.bounty-nameplate: false` removes it.) -- [ ] With PlaceholderAPI installed, `%tradewinds_bounty%`, `%tradewinds_standing%`, - `%tradewinds_reputation%` and `%tradewinds_wanted%` all resolve. Servers using - TAB should turn the nameplate off and use these instead. - -### 6c-iv — Fugitive trade bar -- [ ] As a **Fugitive**, the market at a SAFE or POLICED island refuses to open at - all ("We know what you are"). -- [ ] At FRONTIER and beyond it opens normally. Merely **Wanted** is not barred — - only Fugitive. (`crime.safest-fugitive-trader`.) - -## Interstice: a failed warp must not be an execution - -Playtest: a new player's first warp failed, ghasts opened fire instantly, and they -died having lost everything before they could read the dialog offering the free way -out. Set `travel.warp.failure-chance: 1.0` temporarily to test this repeatedly. - -- [ ] Fail a warp several times. Roughly **40% of the time nothing comes at all** — - dark water and silence (`interstice.ghast-chance`). -- [ ] When ghasts do come, they appear **~90+ blocks away** and are **not already - hunting you**. You can see them, and decide. (They spawn beyond their own - 64-block detection range and are deliberately not given a target.) -- [ ] For **20 seconds after arrival** nothing can target or damage you - (`interstice.grace-seconds`) — long enough to read the dialog and click - re-engage. Confirm a fireball already in the air does not kill you either. -- [ ] Re-engaging is still free, and still returns you to the original destination. -- [ ] Stay past the grace and approach them: it becomes a real fight. The grace - covers the arrival, not the visit. -- [ ] A brand new player with a starter kit can survive a failed first warp without - losing their boat and cargo. That is the actual bar here. - -## Warp arrivals must never land inside anything - -- [ ] Warp repeatedly to the same island from the same origin. You always arrive on - **open water**, never inside the quay decking. (The arrival ring is 130 blocks - from the centre; the quay reaches 136 with its deck at exactly arrival height, - so ~0.9% of bearings used to suffocate you on arrival.) -- [ ] Arrivals still land close to the intended spot - the search takes the nearest - open water, so the island should still be in view. -- [ ] `/twadmin warpfail ` rigs that player's next warp to fail. Run it - again on the same player to clear it. Check the console/chat feedback both ways. -- [ ] After a rigged failure, the flag is gone: the next warp behaves normally. -- [ ] Interstice arrivals also land on open water, not inside the interstice floor. - -## Low fuel warning, and reading the boss bar - -- [ ] Stand at a port with less fuel than the cheapest charted warp costs: a **LOW - FUEL** action bar appears and **repeats** every 8 seconds while you are there. -- [ ] A **chat line** also arrives, once per port visit, saying how much more fuel - you need. It stays in the chat log — this is the one a child will still find - after looking away. -- [ ] Open the market: the body says LOW FUEL, and the button that actually sells - fuel is relabelled **"BUY FUEL HERE"** — the Outfitter normally, or the Buy - Goods button at islands whose own catalog stocks fuel. -- [ ] Buy fuel: all three warnings stop. -- [ ] With nothing charted to warp to, there is **no** warning — being unable to - warp is not a fuel problem then. -- [ ] Out at sea (not at a port), no warning. It is only raised where it is fixable. -- [ ] `travel.fuel-warning.margin: 1.5` warns while you can still just about leave; - `enabled: false` removes all of it including the dialog highlight. - -- [ ] The navigation boss bar now reads **name | band | standing | dock distance**, - with the band coloured — blue Safe, green Policed, yellow Frontier, red - Lawless, bold dark-red ANARCHIC. Sail into each and confirm you can tell at a - glance what kind of water you are in. -- [ ] The same coloured band appears on the chart holograms and the market subtitle. - -## /tw go - the door into the ocean, and nothing more - -`/tw spawn` was a free warp back to a market once spawn became a working trading -post. But deleting it locked new players out entirely, so it is now a door with -two rules. - -- [ ] **From another world** (or on first ever join): `/tw` or `/tw go` puts you - into the ocean. A brand new player arrives at the spawn port with a starter kit. -- [ ] **A returning player arrives where they LEFT the ocean**, not at spawn. Sail - a long way out, `/mv tp world` (or `/acid`), then `/tw` - you should be back - in the same stretch of water, not at a market. -- [ ] **Already at sea**: `/tw go` refuses ("You are already at sea..."). This is the - exploit that had to close - there is no commanding your way to a market. -- [ ] Log out at sea, log back in: you are where you left off, and `/tw go` still - refuses. -- [ ] Dying still respawns you at the spawn plaza (not a cheat - you lost your cargo - to get there). -- [ ] `/tw restart` still returns a destitute player to spawn with a fresh kit, - capped by `player.max-restarts`. -- [ ] The old `/tw spawn` label still works as an alias, and does the same thing. - -## Dock marker on the hologram chart - -- [ ] Inside an island's waters, `/tw chart` (or boarding a boat) now raises a - **DOCK** hologram alongside the island names, pointing at that island's pier - with its distance. Row toward it and the number falls. -- [ ] It points at the **pier**, not the island centre - check on an island whose - dock faces away from you. -- [ ] It hangs **below** the island name markers and has a warmer background, so it - never collides with their stack. -- [ ] Out in open ocean, away from any island, there is no dock marker - just the - island names. -- [ ] With an empty chart but inside an island's waters, the dock marker still - appears on its own. (Previously an empty chart showed nothing at all.) -- [ ] The label is translatable: `tradewinds.hologram.dock`. - -## Star chart: fuel range, reachability, readable names - -- [ ] Hold the Star Chart: island names are **white** and clearly readable against - the blue ocean (they were a mid grey that all but vanished). -- [ ] A **dashed ring** is drawn around you at the limit of your fuel range. Burn or - buy fuel and it shrinks/grows on the next redraw (about a second). -- [ ] With no fuel there is no ring; with a huge amount the ring is off the chart and - is simply not drawn, rather than being clamped to the edge and lying about it. -- [ ] Islands inside the ring are the ones you can afford to warp to - cross-check - against `/tw chart list`. -- [ ] `/tw chart list` shows **Fuel aboard: N units** and marks every entry with its - fuel cost: green and plain when reachable, greyed with "(not enough fuel)" when - it is not. -- [ ] Standing at a port, the quoted cost matches the warp dialog exactly (it uses - the same route price, including any lane overrides in - `travel.warp.edge-overrides`). -- [ ] Adrift between islands the cost is an estimate from your position - close - enough to plan by. It should still fall as you approach a destination. - -## Dialogs close under attack; no warping out of a fight - -- [ ] Open any dialog (warp, market, shipwright) and take damage: the dialog - **closes**. A modal screen hides the boat, the water and whatever is shooting. -- [ ] Any damage does it, not just mobs - drown while reading a shop menu. -- [ ] With a hostile mob within 12 blocks, the warp dialog **refuses to open** and an - action bar says "You cannot warp while enemies are close." Same idea as a bed - refusing to let you sleep. -- [ ] Kill or outrun the mob and the dialog opens normally. -- [ ] Let a mob arrive **while the dialog is already open**, then click a - destination: the warp is refused, and no fuel is taken. (Checked again at the - moment of engaging, because that is when the fuel is spent.) -- [ ] With `travel.warp.stand-still-seconds` above 0, a mob arriving during the - countdown aborts the warp and **refunds** the fuel. -- [ ] **A customs chase cannot be escaped by warping** - the patrol counts as - enemies. Run, fight or jettison, as designed. -- [ ] **The interstice re-engage still works with ghasts nearby.** This is the one - exemption and it matters: it is the way out of somewhere dangerous, and gating - it would strand players. -- [ ] `travel.warp.enemy-radius: 0` disables the gate (dialogs still close on damage). - -## Star Chart is ephemeral - -- [x] `/tw starchart` puts the chart straight **into your hand** (a free hotbar slot, - selected for you) rather than somewhere you have to hunt for. -- [x] **Switch to another hotbar slot: the chart is gone.** Run the command again to - consult it - it is an instrument, not cargo. -- [x] Run `/tw starchart` twice: you end up with **one** chart, not two. -- [x] Try to drop it: it vanishes rather than bobbing in the sea. -- [ ] Die holding it: it is **not** in your death drops, and nobody can salvage one. -- [ ] Log out holding it, log back in: gone, and the command still works. - - Log back in - it is in my hand. This is OK actually. -- [x] Sanity check the point of all this: after a dozen `/tw starchart` calls there - are no stray maps in your inventory, on the ground, or in your hold. -- [x] With a completely full hotbar it is added anywhere it fits rather than refused. - - True, but if your inventory is completely full, there is no error to say that your inventory is full so it cannot be given. - -## The interstice: lit, lidded, and ghasts you can actually see - -**Needs fresh interstice chunks** - delete `world/dimensions/minecraft/tradewinds_world_nether`. -Use `/twadmin warpfail ` then `/tw warp` to get there on demand. - -- [ ] **The ghasts are visible and close enough to identify** - about 28 blocks, in - a tight band (the spread used to add up to 30 blocks on top of the setting, - which quietly undid it: a base of 44 arrived as far out as 74). -- [ ] **They come for you when the grace runs out.** They arrive without a target so - landing is not an ambush, but after 20 seconds anything nearby acquires you. - Previously they never re-aimed at all, so they drifted and nothing happened. -- [ ] The re-engage dialog has a **"Stay a while"** exit button. Declining costs - nothing - the offer comes round again and the fuel is already spent. -- [ ] Some failures still bring nothing at all (`ghast-chance: 0.6`). -- [ ] The 20-second arrival grace still holds - nothing targets or hurts you while - you read the dialog. -- [ ] **There is a ceiling** about 48 blocks above the sea: netherrack with bedrock - on top. Fly up and confirm you cannot leave. -- [ ] **Burning braziers** - netherrack outcrops rising out of the water with fire on - top - appear across the sea, roughly one chunk in six. They light the place and - give you something to steer by. -- [ ] The fires stay lit (netherrack burns forever) and do not spread - there is - nothing out there to catch. -- [ ] Some braziers have glowstone at their base, visible from underwater. -- [ ] Overall: the interstice reads as somewhere hostile you are trapped in, rather - than an unfinished black void. - -## Contraband: pockets count, and the patrol gives you room - -- [ ] **Sugar in your normal inventory slots is scanned too**, not just the hold. - Try the obvious dodge: move the sugar from a pouch into your pockets, cross a - border, stow it again. You are still caught. (Hold-only scanning made that a - free pass.) -- [ ] Being caught seizes contraband from **both** the hold and the pockets - check - the item count in the message against everything you were carrying. -- [ ] A patrol now surfaces about **45 blocks** away, comfortably beyond a guardian's - 15-block laser, so there is a real moment to turn and run. They should not be - hitting you before the warning has finished printing. -- [ ] Warp into an island: you arrive **clear of the shore**, not on the beach or in - a bay. (About 4% of approach bearings used to land on or beside land, because a - warped coastline can reach the arrival ring; arrivals now step outward along - the approach, never sideways or inward.) - -## Customs patrols launch from the dock - -Watch the console - every dispatch now logs the player, the pier, the launch -point, and each unit's distance. - -- [ ] Trigger a customs scan. The patrol appears **at the island's pier**, not - beside your boat, and swims out to you. The console line shows the launch - point and how far it is from you. -- [ ] The warp arrival is logged too: coordinates and distance from the island - centre. Cross-check it against where the patrol starts. -- [ ] There is a **phantom** in the patrol - the only unit that can catch a boat. - The guardians and drowned should fall behind if you run. -- [ ] Running works: get clear of the border and the chase breaks off. -- [ ] **A second, innocent player standing nearby is never hurt by the patrol** - - not by a guardian beam, not by a trident. Try mooring next to the smuggler. -- [ ] The patrol still switches back to the smuggler if it wanders. - -## Warp arrival distance and patrol reachability - -- [ ] A warp now lands you just inside the island's **visible border** (~320 blocks - from the centre), where the warp dialog offers itself on the way out - not on - the island's underwater shelf. The nav bar and dock hologram give you the - heading. - - ~~Fail - arriving at 400 landed exactly ON the warp-offer ring, so the warp - dialog reopened the moment you arrived.~~ - - FIXED (retest): arrival default is now 320 (inside the ring), and arrival also - seeds the prompt cooldown, so even a config that lands on the ring cannot - reopen the dialog. Update `travel.warp.arrival-distance` to 320 in the live - config. Retest: warp anywhere - no dialog on arrival; row inward, trade, row - out - the dialog appears at the border crossing as before. -- [ ] A customs patrol launches from the pier if you are near it, otherwise from as - far along the way as the server will simulate (~80 blocks). Check the console - line: the launch point should never be more than that from you. -- [ ] **The patrol actually moves.** Anything beyond ~160 blocks (simulation - distance) never ticks at all, which is what "they spawned but nothing - happened" was. -- [ ] Let a chase run past 2 minutes without leaving island space: you are told the - patrol **gives up and turns for home** - not that you reached open water - and - you are NOT flagged at that port. -- [ ] Genuinely outrun a chase across the border: you still get the open-water - message and you ARE flagged. - -## Chart holograms stay above the horizon - -- [ ] In a boat in island waters, `/tw chart`: the **DOCK** marker floats clearly - above the waterline - never submerged or straddling the horizon, however far - away the pier is. -- [ ] The island-name signs stack **above** the dock marker (dock lowest, nearest - island next, further islands higher) with no overlap. - -## Islet biomes follow the sea temperature - -- [ ] Sail a frozen sea: islets there are snowy (snowy plains/taiga, ice spikes, - grove, frozen peaks) with snowy beaches; no jungle or desert in sight. -- [ ] Sail a warm sea: desert, badlands, bamboo jungle and mangrove islets. -- [ ] Temperate water: forest-family islets, including the rare **cherry grove** - and **pale garden** (pale oaks, pale moss - and creakings at night, so - approach one after dark deliberately). -- [ ] Vanilla decoration matches the biome: cherry trees on a cherry islet, pale - oaks on a pale garden islet, cacti/dead bushes on desert ones. -- [ ] Mushroom islets still roll ~6% anywhere, mycelium with no beach. -- [ ] Pre-existing explored areas keep their old islet biomes; a chunk seam through - a half-generated islet MAY show a biome change (accepted on the test server). - -## Ground truth: surfaces match the biome - -- [ ] A desert islet is SAND (sandstone beneath when you dig); badlands are red - sand over terracotta; mangrove islets are mud; old-growth taiga is podzol; - stony shores/peaks are bare rock; groves and snowy slopes are snow blocks. -- [ ] Trading islands too: a desert or badlands INDUSTRIAL port is a sand/red-sand - island. The plaza and quay are unchanged (their own surfaces). -- [ ] Spawn (mangrove FISHING) generates mud in NEW chunks - a seam against old - grass chunks is expected on this test world; a fresh world is seamless. - -## Islet structures (galaxy.islet-structure-chance, default 0.25) - -- [ ] Roughly one islet in four has something at its heart: igloo on snowy islets, - half-buried fossils on desert/badlands/swamp ones, a ruined portal (check the - loot chest works) in jungle/forest, tents/log piles/target ranges on - plains/savanna. -- [ ] Same seed, same islet, same structure after a world regen (determinism). -- [ ] Mushroom and pale garden islets NEVER have one. -- [ ] Structures sit sensibly: fossils partly buried, igloo floor flush, no - floating or truncated templates at chunk edges. -- [ ] Set the chance to 0 and generate new area: no structures; back to 0.25: they - return (safe mid-game toggle). - -## The plaza galley: public crafting and cooking - -- [x] Every plaza has a crafting table and a campfire on a cobblestone hearth near - where the quay meets the plaza (NEW chunks only - previously generated - plazas keep their old layout). -- [x] As a plain visitor: open the crafting table (3x3 grid works) and cook raw cod - on the campfire (right-click with the fish, collect cooked cod). -- [ ] Cooked/crafted goods are unstamped: the market refuses to buy them (eat your - catch, don't sell it). -- [x] The table and campfire cannot be broken by visitors. -- [x] Villagers and golems do not walk into the campfire (watch a minute or two). - -# ============ THE GREAT REFIT (virtual hold / One Boat / tech) ============ -# PREREQUISITE: wipe the TradeWinds world folders AND the TradeWinds database -# tables (PlayerHold, DroppedBoat, TWPlayerData, TWIslandData) - clean slate, -# no migration. Old pouches/stamped goods/shulker expanders are dead items. - -## Tech levels - -- [x] Every island announce/market/chart line shows its tech: "Esedaxe - - FISHING (Tech 2), Safe" etc.; nav bossbar shows "Tech N". -- [ ] The starter cluster contains at least one Tech 3+ island. -- [ ] Prices tilt: iron/gold ingots cheaper at a Tech 6 INDUSTRIAL island than - a Tech 2 one; raw ore sells for MORE at high tech. -- [x] Plaza amenities by tech: TL3+ furnace + stonecutter; TL4+ smithing table - + grindstone; TL5+ brewing stand + cauldron; TL6+ anvil; TL7 enchanting - table with a bookshelf arc. All usable as a visitor, none breakable. - -## Virtual hold and the GUI - -- [x] New player: Oak Boat, 8 coal already in the FUEL row, $250. No pouch. -- [x] Open the hold: sneak-right-click the boat (confirmed working); - right-click riding (empty hand); right-click the boat item in your - inventory screen; and in a CHEST-variant boat, just press your inventory - key while riding - the hold opens like a vanilla chest boat (top = hold, - bottom = your inventory). NOTE: in a plain (non-chest) boat the inventory - key cannot open the hold - the client never tells the server about it - - so use the right-click gestures there. -- [x] A chest boat's REAL chest inventory is unreachable by every route - (inventory key, sneak-click, riding click) - the hold GUI answers instead. -- [x] The GUI: TNT top-left, 3/21 cargo slots open (rest gray), 7 fuel slots - (pink panes when empty). -- [x] Click items in your pack to deposit: coal/logs/lava go to FUEL; bundles, - chests, shulkers and boats are REFUSED; anything else becomes cargo. -- [x] NOTHING gets cargo back out: shift-clicks, number keys, drags, cursor - tricks all dead. Fuel clicks DO come back out. -- [x] Select a cargo stack, click TNT: destroyed (gone, not dropped). -- [x] Coal/logs BOUGHT at market sit in cargo; clicking that cargo stack moves - it to the fuel row (works inside expander panels too). Fuel can never be - TNT-destroyed - clicking fuel-valued cargo always moves it instead. -- [ ] Deposited items lose enchantments/names by design - cargo is commodity. -- [ ] Sell at a market: anything aboard sells (no stamps); buying fills the - hold and stops at capacity; balance/slots line is right. - -## One Boat - -- [x] Shipwright lists only boats BIGGER than yours, only up to TL x 3; buying - swaps your boat in place (riding: re-seated in the new boat) and your - cargo/fuel are untouched. The old boat is GONE. -- [ ] Crafting a smaller/equal boat is refused with a message; crafting bigger - replaces like the shop. Boat+chest recipe upgrades in place. -- [ ] Charity (no boat, broke): a bamboo raft. /tw restart: everything reset. - -## Dropped boats - -- [ ] Q-drop your boat item: cargo goes WITH it. Another player picks it up: - no boat = it becomes theirs, contents intact; smaller boat = they strip - the valuables first, hull+remainder stays. -- [ ] Bigger-boat salvage is a SWAP: full oak boat + full cherry chest wreck = - you sail the cherry chest with your cargo plus the most valuable salvage, - and your old oak floats where the wreck was, holding the overflow. Count - the items: NOTHING is lost. If everything fit, the old hull does NOT - linger - it is consumed by the upgrade. -- [ ] A PLAIN boat item (command-given, or a spare hull) upgrades on pickup - too: /give yourself an OAK_CHEST_BOAT, drop it, pick it up - it replaces - your oak boat, your cargo stays, and the old oak is gone. A smaller - plain boat just sits in your pack; a boatless player claims it. -- [ ] Break a ridden boat (punch/trident): always an item, never destruction; - pick it back up and your hold survives the round trip. -- [ ] Boat + hold burn ONLY in lava (boat entity in lava, or fire/lava death). -- [ ] Death drops the tagged boat; DeathChest servers: it is in the chest. -- [ ] Respawning boatless at spawn: the port lends you a BAMBOO_RAFT (message - says your own boat is adrift where you fell). With keepInventory on you - keep your boat and get NO loaner. `boats.respawn-boat: NONE` disables it; - an invalid material name also just disables it. -- [ ] Tagged boat items never despawn at 5 min; they sink when the TTL - (default 30 min, survives restart) runs out. - -## Expanders v2 - -- [ ] Sold only at Tech 7 shipwrights; refuse without a Pale Oak Chest Boat - with a free slot; price 5000, 10000, 20000... -- [ ] Installed expander shows as a white shulker in the hold GUI; click opens - its own 21-slot panel (own TNT, no fuel row); refuses containers. -- [ ] Right-click an EMPTY expander + TNT destroys it; loaded ones refuse. -- [ ] In any smaller boat (after salvage) expanders are inert but their - contents still sell at market. - -## Visible boundaries (border.* config) - -- [ ] Approaching an island, a BLUE dust curtain marks the edge of island space - (where scans begin), and further in a RED curtain marks the warp-offer - ring at the protection edge. Both render only the arc near you (~32 - blocks) and only when you are close. -- [ ] Both are pure paint: you row straight through them. -- [ ] Colors change with border.warp-ring-color / border.edge-color (r,g,b); - a nonsense value falls back to the defaults instead of vanishing. -- [ ] border.particles-enabled: false removes both curtains. - -## The warp dialog fires AT the red curtain - -- [ ] The dialog opens only as you touch the red particle wall (~5 blocks - either side of it), not 30 blocks early. Rowing up short of the curtain: - nothing. NOTE: needs `travel.warp.trigger-distance: 5` in the LIVE - config - the jar default changed but an existing config keeps its 30. -- [ ] Sprint across an icy approach lane through the curtain: the dialog must - still fire (the window is small but not skippable). - -## Warp arrivals face the dock - -- [ ] Both the BOAT and your view come out of a warp pointed at the dock - - mounting aligns the hull to the rider, so both rotations are set after - the re-seat. - -- [ ] Warp to any island: you and your boat arrive pointing AT the pier - - paddle straight forward and you reach the dock. Check from a couple of - different origin islands (arrival side changes; the facing must not). - -## Boat ownership, capture and protection (2026-08-02 rules) - -- [ ] The boat ITEM shows its manifest as lore: "Cargo: X/Y slots", - "Fuel: N units", "Right-click to open the hold" - and the numbers update - after you close the hold GUI (deposit fuel, close, hover the item). -- [ ] The starter message names the Oak Boat, its 3 slots and the fuel tank, - and says to right-click the boat; arriving at spawn says you come - ashore at the spawn port (no more "set sail" while standing on planks). -- [ ] Place your own boat from the item and board it: NO capture dialog, no - OLD BOAT marker, and your fuel/cargo are still aboard (regression: the - placed entity used to lose its identity). -- [ ] Teleport (/tw spawn) while riding: the boat item in your pack still - carries its cargo and fuel when you place it again. -- [ ] Name plates: an unattended boat shows its owner's name; an abandoned one - shows UNOWNED. The plate hides while someone is riding it. -- [ ] Trading needs your ship: leave your boat outside the island's protection - range (or on another island) and the market refuses with "your ship is - not at this port". Carrying the boat as an item counts as having it. -- [ ] `/tw chart` raises the hologram compass ashore as well as afloat (text - list only via `/tw chart list`): green BOAT marker at your ship, red - OLD BOAT at one you abandoned - from anywhere in the world. Walk away - from a moored boat, run it, follow the marker back. -- [ ] While SITTING in your boat there is no BOAT marker (you are in it), nor - while CARRYING it as an item, nor when it is within ~32 blocks - a - marker is only for a boat you must travel to. -- [ ] After swapping boats at your feet, neither BOAT nor OLD BOAT clutters - the chart; walk a few hundred blocks from an abandoned LOADED hull and - the OLD BOAT marker does appear. -- [ ] `/tw chart list` names both boats with coordinates and distance ("Your - Oak Boat: x, z (N blocks)" / "Old Bamboo Raft: ... unowned"), or says - plainly that you have no ship / that a boat is in another world. Use - this when a hologram marker is missing: it tells you whether the boat - is gone, elsewhere, or just not drawing. -- [ ] Capture a boat: the one you left changes its plate to UNOWNED - immediately (regression: it kept showing the old owner's name). -- [ ] Board someone else's unattended boat in SAFE/POLICED/FRONTIER/LAWLESS - island space: refused ("this port protects it"). Same for breaking it, - including mob damage. On an ANARCHIC island: allowed. -- [ ] Board an UNOWNED boat anywhere (even at a safe dock): confirmation - dialog; accept and it becomes yours, your previous boat becomes UNOWNED - where it lies with your cargo still in it, charted as OLD BOAT. -- [ ] Walk over an unowned boat ITEM with a boat of your own: the dialog opens - once (not every tick) and offers "Take the [boat]", "Take only the cargo" - (only if your own boat is within 200 blocks AND the hull carries - something), and "Leave it". Take the boat: yours, old one becomes OLD - BOAT. Take the cargo: hull stays on the ground, cargo in your hold. -- [ ] Repeat with your own boat on ANOTHER island: the cargo option is GONE - and the dialog says your boat is too far to load. Nothing teleports. -- [ ] Boarding someone's unattended hull offers the same three answers. -- [ ] Die: your boat stays floating where it was and is still yours. Respawn - at spawn with the ship far away: you are lent a bamboo raft. Board the - raft -> confirmation -> your real boat becomes OLD BOAT; row back to it, - board it, and it is yours again (marker clears). -- [ ] Lava: the only thing that destroys a boat and its cargo outright. -- [ ] Log out in protected island space: boat is safe. Log out in open water: - another player can take it; on your next login you are told it was - taken, and given a raft if you are adrift with none. - -## Two-player capture (2026-08-02 regressions) - -- [ ] Player 2 is ONLINE when player 1 takes their boat: player 2 is told - immediately ("Someone has taken your boat!"). -- [ ] Player 2 now has NO boat: the market shows no cargo slots, gives no - low-fuel warnings, and `/tw chart` shows NO old boat (it was taken, not - abandoned - an OLD BOAT marker only ever means unowned and still there). -- [ ] Player 2 can still use the OUTFITTER and the SHIPWRIGHT with no boat - - buying a hull is how they get off the island. Only Sell/Buy cargo are - withheld, with a line saying why. -- [ ] With NO boat, buy a Bamboo Raft ($100): the raft item is IN YOUR PACK, - the money is gone once, and the hold GUI opens on it with 2 slots. -- [ ] With a boat AT the port, buy a bigger hull: the same record refits - the - boat you are riding is swapped under you (or the carried item retypes), - cargo intact. With your boat elsewhere, the yard refuses the refit and - says to bring the ship in. -- [ ] Restart the server: player 2 gets no repeat "taken while you were away" - message, and player 1 still owns the boat (the login path used to strip - the new owner and hand it back). -- [ ] Player 2 offline when it happens: they ARE told at their next login, - once, and still have no OLD BOAT marker. - -## Recovering a boat while carrying one (no duplicates) - -- [ ] Carry your boat as an item, walk to your abandoned OLD BOAT and take it: - you end up with ONE boat item (the one you took), its hold holding both - cargoes, and the old hull lying on the ground unowned. Never two boats - in the pack. -- [ ] Right-clicking a boat item that is NOT your ship does nothing (it used - to open your hold if the material matched). -- [ ] Craft a bigger boat: the crafted item is your ship (right-click opens - the hold), not an anonymous look-alike. -- [ ] With your old boat an island away, taking a new one leaves it untouched - with its cargo - charted as OLD BOAT. diff --git a/files.zip b/files.zip deleted file mode 100644 index 090338c1ef998ae073d8873ec3566ed6b56ee1d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13665 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 0 (clean players show nothing); PlaceholderAPI placeholder exposed so nametag plugins (TAB etc.) can render it themselves instead of conflicting. Note: sneaking hides nameplates on foot, but players in boats cannot sneak — traveling pirates are always visible. -- Police mobs by mobility, not just element: **Phantoms pursue** (fast; combustion cancelled or day patrols burn at dawn), **Guardians hold the sea perimeter** (slow swimmers who will never catch a boat — they are area denial around the island, not chasers), **Golems** handle anyone who lands. All PDC-tagged, ticking `setTarget` reassertion, **zero drops** (or wanted players become a crime-powered iron farm). Pursue within the protection zone, break off when the target crosses the visible border (escape you can see), despawn beyond island range or on unload. -- Per-band knobs: scan chance **and** response size — a mid-band island may scan rarely but respond hard. Route planning with contraband should be map-reading skill. -- Anti-bait guard for innocent-kill penalties: direct kills only; prior damage from the victim exempts. Log edge cases, tune later; do not over-engineer pre-MVP. - -## Proposed but not yet adopted (needs Ben's sign-off) -- Concrete reputation numbers: −1000..+1000 scale; hit villager −5, kill villager −25, kill police −15, kill innocent −100, scan-caught −30 + fine, slave sale −20; decay ~+1 per 15 min clean active play; band thresholds +250 / 0 / −200 / −500. -- **Regional density = civilization**: occupied-position density higher near spawn, thinning outward, with security band correlated to local density (clusters civilized, isolated islands anarchic). Makes the map itself communicate risk. -- Wanted players suffer a higher warp-failure chance (crime feeds the interstice ambush meta). -- Interstice is a **shared** world (enabling warp-space interdiction/ambush — very Elite) rather than instanced pockets. -- Post-MVP: stall rental on NPC islands for the player market (goods join the island's trade pool; rent as sink; stall location matters via the route graph); insurance (hull/expanders insurable, cargo never); "last seen" wanted-player intel sold at trade posts; Elder Guardian as the top-tier wanted response (its fatigue aura hits innocents — `EntityPotionEffectEvent` cancellation is possible but fights the mob's design, hence demoted from standard police). - -## Open questions -1. **Name — RESOLVED: TradeWinds** (one word, house style like BSkyBlock/AcidIsland; repo `TradeWinds` under BentoBoxWorld). "Trade Wars MC" rejected — the TradeWars 2002 mark is still live, and "Wars" mis-signals a trading game; Sandlot's Tradewinds series is defunct and the phrase is generic. -2. **Poseidon internals:** can a radial positional mask multiply into its existing density tweaks, or is it cleaner to lift its sea-level/decoration handling into a fresh generator with the mask native? (First real Stage 1 task.) -3. **Dock placement:** deterministic terraformed shelf at a fixed bearing (predictable, teachable) vs. jigsaw terrain-adaptive placement (organic) — current lean: deterministic dock, adaptive decorations. -4. **Border interaction:** "click the wall" needs packet-level handling of client-side blocks; proximity trigger + action-bar prompt is cheaper and reads the same. Pick one; Border must run in passable/visual mode regardless. -5. **Boat loss rules:** what drops on boat destruction / player death? Full expander drop makes trader-piracy the apex game (very EVE) but is brutal; insurance is the counterweight. Undecided. -6. **Interstice PvP rules** if shared: can hunters lawfully take non-wanted players there, or is interdiction itself a rep crime? -7. **Scan/flag tuning:** scan cooldown length, flee-flag duration, "caught" proximity radius. -8. **Galaxies architecture:** multiple configured addon instances (AcidIsland/BSkyBlock coexistence model) vs. a world manager inside one addon. Deferred, but keep the world reference abstracted from day one. -9. **Villager shoreline safety:** golems can't path onto water; keep villagers inland behind the docks so drive-by boat raids don't trivialize the police, or add a ranged deterrent. -10. **Reputation numeric tuning** (see proposal above) and whether reputation is global or per-galaxy once galaxies exist. diff --git a/files/tradewinds-hold-plan.md b/files/tradewinds-hold-plan.md deleted file mode 100644 index 2aaacc3..0000000 --- a/files/tradewinds-hold-plan.md +++ /dev/null @@ -1,132 +0,0 @@ -# TradeWinds — Virtual Hold & Boat Mechanics: Implementation Plan -*For Claude Code. Merged from Ben's "# Boat mechanics.md" (authoritative on conflicts) and the earlier virtual-hold plan. Server-authoritative holds in the database; no real containers ever hold cargo. Clean-slate build, no migration.* - -## Core model - -**One Boat Rule: a player has exactly one boat, or none.** The hold is conceptually the *player's* cargo inventory; the boat they possess only sets its size. Upgrading never moves items — the slot count grows and existing contents stay put. - -Storage is database-backed (BentoBox Database API); the boat item/entity is a key. While a player owns a boat, the hold is keyed to the player; when the boat becomes a dropped item, the hold contents travel with the item (item-keyed record with the hold UUID in the ItemStack PDC) until picked up or expired. - -## Boat ranks (defaults; `config.yml` map `boat-material → slots`) - -| Rank | Boat | Slots | | Rank | Boat | Slots | -|---|---|---|---|---|---|---| -| 1 | Bamboo Raft | 2 | | 11 | Dark Oak Boat | 12 | -| 2 | Oak Boat | 3 | | 12 | Jungle Chest Boat | 13 | -| 3 | Spruce Boat | 4 | | 13 | Mangrove Boat | 14 | -| 4 | Bamboo Chest Raft | 5 | | 14 | Acacia Chest Boat | 15 | -| 5 | Birch Boat | 6 | | 15 | Cherry Boat | 16 | -| 6 | Oak Chest Boat | 7 | | 16 | Dark Oak Chest Boat | 17 | -| 7 | Jungle Boat | 8 | | 17 | Pale Oak Boat | 18 | -| 8 | Spruce Chest Boat | 9 | | 18 | Mangrove Chest Boat | 19 | -| 9 | Acacia Boat | 10 | | 19 | Cherry Chest Boat | 20 | -| 10 | Birch Chest Boat | 11 | | 20 | Pale Oak Chest Boat | 21 | - -## Expanders - -- Install **only in a Pale Oak Chest Boat** (the top rank). An installed expander **occupies one C slot**; clicking it opens a nested inventory panel — same style, own TNT destroy slot, **no fuel row**. -- Proposed defaults *(veto welcome)*: nested inventory is **21 slots** (net +20 per expander); expanders cannot contain other expanders or any container item; an expander can be dragged to TNT **only when empty**; nested inventories are openable only while the expander sits in a Pale Oak Chest Boat's hold (an expander that ends up elsewhere via drop/pickup rides along as inert cargo, contents preserved, until installed again or destroyed); sold at shops only, and only at **top-tech (TL7) islands** — the endgame sink has a home port. -- No installed-count cap needed: the 21 C slots are the natural ceiling, and doubling prices (below) make the tail aspirational. A fully expanded ship approaches ~440 slots and a nine-figure spend — the "guard it with your life" endgame. - -## Pricing (defaults; formulas in config) - -- **Boats — quadratic:** `price = 25 × slots²`. Anchors: Bamboo Raft 100, Birch 900, Jungle 1,600, Mangrove 4,900, Pale Oak 8,100, Cherry Chest 10,000, Pale Oak Chest 11,025. Early upgrades are pocket change; the top rungs cost real trading profit but stay reachable — correct, since boats are also craftable and the shop is a convenience for players who haven't found the rarer groves. -- **Expanders — doubling:** `price = 5,000 × 2^(installed)`. First 5,000; fifth 80,000; eighth 640,000; tenth 2.56M. The curve *is* the endgame money sink — no cap required, the wallet is the cap. - -## Hold GUI (BentoBox Panel API) - -Six rows: - -``` -X T X X X X X X X -X C C C C C C C X -X C C C C C C C X -X C C C C C C C X -X X X X X X X X X -X F F F F F F F X -``` - -- **T — TNT destroy slot.** Lore: "Drag cargo here to destroy them." Dragging cargo onto it removes the items from the game (destroyed, NOT dropped into the world). Fuel dragged here is never destroyed. -- **C — cargo slots**, up to 21, unlocked left-to-right/top-down to the boat's capacity; locked slots render as border panes (X). -- **F — fuel slots**, 7, shown as pale-red glass panes with lore "Fuel Slot" when empty. Fuel dropped anywhere in the GUI routes to a fuel slot and stacks where possible. Fuel is freely added, moved, and **removed** — fuel does not compete with cargo and is not subject to one-way rules. Lava buckets occupy a fuel slot each; on consumption the lava is burned and the empty bucket returned. -- **X — light-blue glass border**, no lore. - -**Contents auto-consolidate:** items are always stored in optimal stacks, never spread — slot usage is always minimal. - -### Removal rules -Cargo can leave the hold only by being **sold** or **destroyed** (TNT slot). Transfers out to player inventory, drops, hotkeys, shift-clicks, and hopper-adjacent tricks all must fail (adversarial test list required). - -### Insertion rules -**Any item may be added to the hold, one-way**, except pouches, bundles, shulker boxes, or any other container item (they would expand effective hold size). Sole exception: official Cargo Expander items, in Pale Oak Chest Boats only. If an island won't buy what you inserted, it simply wastes space until you destroy it. Customs stamping is **removed entirely** — capacity and the stock/demand pools carry the balance. - -## Opening the hold -- Sitting in the boat + right-click — chest boat or not. -- Sneak + right-click the boat entity. -- Right-click the boat item in your inventory. - -## Obtaining and upgrading boats - -Three routes: **shop**, **craft**, **pick up a dropped boat item**. - -### Shop -- Shops list only boats **bigger** than your current boat, priced per the quadratic formula above, and only up to the island's **tech level**: ranks sold ≤ TL × 3 on a 1–7 scale (config), so top boats exist only at high-tech ports. Tech gates shops only — never docking, riding, or crafting. -- Purchase replaces your current boat — **the replaced boat is destroyed**; you are buying an upgrade, not a second boat. (Grants a boat if you had none.) Contents persist untouched — capacity simply increases. Example: Pale Oak Boat (18 slots, full of granite) upgraded to Cherry Chest Boat (20) → same 18 stacks of granite, 2 new empty slots. - -### Crafting -- Same replacement semantics. Crafting a boat **smaller** than your current boat is blocked (craft event cancelled with a locale message). -- A player may add a chest to their current boat at any time (vanilla boat+chest recipe) — an in-place upgrade to the chest variant's capacity. - -### Dropped boat items -Boat items exist when thrown, dropped on death, or when a boat entity is broken. The hold contents travel with the item. - -1. **TTL:** every dropped boat item gets an expiry timestamp **stored in the database** (survives restarts/crashes). A periodic task removes overdue items by UUID, along with their contents. -2. Pickup, item **bigger** than your boat: it immediately replaces your boat — **the replaced boat is destroyed** (upgrade semantics, same as the shop); your capacity grows; your existing contents remain and the captured contents merge in. -3. Pickup, item **smaller or equal**: you keep your boat; contents transfer from the captured item **most-valuable-first** (BlueBook base price as the value oracle) until your hold is full. -4. If your hold fills mid-transfer, you're told the transfer is partial; the remainder stays in the dropped item (still under TTL) for anyone — including you, after destroying or selling to make room. -5. **Sanctioned use:** dropping a loaded boat so another player can pick it up is the intended way to hand goods over. Not an exploit. -6. Pickup with **no current boat**: the item becomes your boat with its contents intact *(assumption — confirm)*. - -### Edge cases (from Ben's doc) -- If the dropped item entity is destroyed by external means (another plugin, etc.), the inventory is lost — accept and clean the DB record. -- Boats must never be destroyed **except by lava**: trident hits, collisions, and other breakage always yield the item. -- On player death, the boat drops as an item with contents (except lava deaths). **`keepInventory` servers/regions:** the player simply keeps their boat and hold. **DeathChest servers:** the boat goes into the death chest; the player must buy a boat and fuel and physically travel there (no teleporting per DeathChest rules); on retrieving their old boat, normal pickup/upgrade rules apply and the recovery boat — being smaller — is the one removed. -- A mob (e.g. zombie) picking up the boat item: **works for free under this model** — contents are DB-keyed via the ItemStack's PDC, which survives mob pickup; kill the zombie, the item drops, hold intact. TTL handling while mob-held: pause or extend TTL while an entity holds it (recommend: pause). -- Overfull situations (e.g. capacity shrinks by admin action): player must choose what to keep; open the GUI in triage mode where excess must be destroyed before normal use resumes. - -## Data model - -``` -PlayerHold (DataObject) DroppedBoat (DataObject) - uniqueId : player UUID uniqueId : hold/item UUID (in item PDC) - boat : material or none boat : material - contents : [{material, amt}] contents : [{material, amt}] - fuel : [{material, amt}] fuel : [{material, amt}] - expiresAt : timestamp (TTL) -``` -Ownership transitions (drop → pickup → replace) are atomic DB moves between the two objects. Duplicating a key item cannot duplicate cargo — contents exist once, server-side. - -## Trade & customs integration -- Plaza trading transacts against the player's hold — with One Boat there is never ambiguity. -- Customs scans the player's hold (and the dropped-boat record if they're carrying a boat item — not possible under One Boat, but guard anyway). -- Smuggler evidence-dumping = destroying contraband via the TNT slot before police close. -- Fuel checks (warp dialog, low-fuel warnings) read the fuel slots only. - -## Resolved rulings (this revision) -1. Expanders **kept**, Pale-Oak-Chest-only, nested-inventory model (see Expanders). -2. Boat pricing **quadratic**, expanders **doubling** (see Pricing) — proposed defaults awaiting veto. -3. Replaced boats are **destroyed** — purchases and bigger-boat pickups are upgrades. -4. Customs stamping **removed entirely**. -5. `keepInventory` / DeathChest behavior defined (see edge cases). -6. Destroy-only jettison confirmed. - -Remaining proposed-not-ruled defaults, all in the Expanders section: nested size 21, no nesting, empty-before-destroy, openable only while installed in a Pale Oak Chest Boat, sold at industrial islands. - -## Acceptance tests -- One Boat invariant holds through every path: shop, craft, pickup, death, admin gives. -- Upgrade (shop/craft/chest-add) preserves contents exactly; smaller purchases/crafts impossible. -- Dropped-item TTL fires across a server restart; mob-held items survive with contents. -- No GUI interaction sequence extracts cargo to player inventory; fuel moves freely both ways; TNT destroys cargo, never fuel. -- Pickup transfer is most-valuable-first, partial transfers leave the remainder intact and claimable. -- Death always yields the boat item with contents (except lava); trident/collision yields the item, never destruction. -- Expanders: installable only in Pale Oak Chest Boats; nested GUI never accepts containers or expanders; TNT refuses a non-empty expander; nth-expander price = base × 2ⁿ; expander riding in a smaller boat after pickup is inert but its contents survive round-trips. -- Tech gating: a TL n shop never lists boats above rank 3n or (below TL7) expanders; crafting is unaffected by tech level. diff --git a/files/tradewinds-overview.md b/files/tradewinds-overview.md deleted file mode 100644 index 309fee9..0000000 --- a/files/tradewinds-overview.md +++ /dev/null @@ -1,33 +0,0 @@ -# TradeWinds -### A BentoBox gamemode of sea trading, smuggling, and piracy - -**The pitch:** Your players are merchant sailors in an endless procedurally generated ocean dotted with trading islands. They start with nothing but a boat and a single trading bundle, and build a fortune by buying goods cheap on one island and selling them dear on another. How they get rich is up to them — honest hauling, smuggling contraband past customs patrols, hunting bounties, or outright piracy. Every choice has a price, and the game keeps score. - ---- - -## The world -The ocean is generated from a single shareable seed. Trading islands appear at random positions across the ocean — sparsely scattered, and always far enough apart that you can never see one island from another. Each has its own biome, procedurally generated name, and economic character: farm islands sell food cheap, industrial islands want raw materials, and so on. Islands also carry a **tech level** — only advanced ports sell the bigger ships, so wealth alone won't upgrade you; you have to sail somewhere that can build it. Islands generate lazily as players push outward, so the world grows with your community. Villagers run the markets; the islands themselves are protected — no building or breaking. - -Each island has a **security level**, from *safe* (no PvP, no hostile mobs, heavy policing) down to *anarchic* (anything goes). The best profit margins — and the black markets — are in dangerous space. - -## Getting around -Boats are everything. Players row freely between islands, or reach an island's border and **warp**: a fuel-powered jump straight to any charted island, paid for with wood, coal, or lava carried in the boat's hold. Warping is fast but fuel and cargo compete for the same space — the game's central tradeoff. Rowing is free but slow, and the open ocean between islands is lawless. - -Warps occasionally *fail*, dropping the player into a hostile Nether sea mid-route with Ghasts inbound. Fight or re-engage the warp — the detour is free, the fright isn't. - -Cargo grows in stages: bundles → chest boat → purchasable cargo expanders, with costs that scale steeply. A maxed-out trade ship carries a fortune and is worth defending — or attacking. - -## Law and disorder -Every player has a **reputation**. Attacking villagers, killing innocents, or trading contraband (sugar, by default) drives it down; clean living slowly restores it. Slip too far and you're *Wanted*: open to PvP everywhere, carrying a bounty any player can collect, and met at civilized islands by the police — Iron Golems on land, Guardians at sea, Phantoms in the air. A wanted player's bounty hangs right over their head for all to see (configurable) — a target sign for hunters, a badge of infamy for pirates. - -Entering an island's waters triggers a **customs scan**. Get flagged with contraband and the patrol launches — flee, fight, or dump the evidence overboard before they close in. Smuggling is a skill, not a tax. - -Players who earn enough can **buy their own island**, placed in the open ocean between trading islands, and eventually sell their own crafted goods into the economy. - -## For admins -- Runs on **BentoBox** alongside your other gamemodes; prices derive from the **BlueBook** economy addon. -- Nearly everything is a config knob: island spacing, fuel costs and warp-route pricing, scan chances, police response size, reputation thresholds, warp failure rate, cargo expansion caps. -- **Contraband and slave-trading mechanics can be disabled entirely** for family-friendly servers. -- One seed defines the whole galaxy — share it and another server gets the same world. - -*Inspired by the classic trading games TradeWars 2002 and Elite — rebuilt entirely in Minecraft terms.* diff --git a/tradewinds-design-decisions.md b/tradewinds-design-decisions.md deleted file mode 100644 index 1662500..0000000 --- a/tradewinds-design-decisions.md +++ /dev/null @@ -1,141 +0,0 @@ -# TradeWinds — Design Decisions & Open Questions -*Companion to `tradewinds-overview.md` (admin pitch) and `tradewinds-dev-plan.md` (stages). This captures what was decided, **why**, and what remains open. Intended as the seed for `TRADEWINDS_SPEC.md`.* - ---- - -## Design principles (load-bearing insights) - -These are the reasons the design works. If a future change breaks one of these, stop and reconsider. - -1. **The fuel/cargo tradeoff is the central mechanic.** Everything competes for hold space. This only binds because **trading transacts exclusively against bundles and boat hold** — never player inventory, ender chests, or carried shulkers. Any feature that lets sellable goods bypass the hold breaks the game's spine. -2. **Risk symmetry between travel modes.** Warping costs fuel but is instant; its risk is interstice interdiction. Rowing is free but slow; its risk is the lawless open ocean (no protection, no police, PvP live outside island ranges). Neither mode should become strictly dominant. -3. **Scans, not prices, balance contraband.** Sugar has infinite trivial supply (sugarcane farms), so its price cannot be the control. The customs-scan risk is the cost; that is why sugar can stay lucrative and smuggling can be a skill-expressing playstyle rather than a tax. -4. **Crime pays you into danger.** Contraband sells only at less-safe islands; Fugitives are barred from safe-island markets entirely. The richest criminals are structurally pushed into PvP space where bounty hunters can lawfully take them. This one rule closes the entire crime/bounty loop. -5. **The seed is the world.** Island positions, existence, types, biomes, names, and route-graph edge costs must ALL derive deterministically from the single shareable seed — pure functions of (seed, position), unit-testable with no Bukkit dependency. If any of these drifts to runtime randomness, seeds stop being shareable. -6. **Generator-driven terrain, not pasted terrain.** Island landmass comes from the chunk generator (Poseidon-style noise × radial mask), so lazy generation is just chunk generation: no pop-in, no paste pacing. Blueprints/structures decorate; they do not create the land. -7. **Scarcity by dimension removal.** No End world → shulker shells are unobtainable → cargo expanders are a purchasable, cap-controlled money sink. Do not add End access. - -## Decided — by system - -### World & generation -- Islands at fully **random ocean positions** (not grid-spaced), sparse by design, seeded; rejection-sampled against a minimum separation so protection zones never overlap (spatial-hash grid lookup at placement time). Blank ocean between islands; some space reserved for purchased player islands. -- **Density floor near spawn** guarantees a starter cluster regardless of seed luck. -- Whole-island biomes set per column at generation; vanilla decoration provides theming. Frozen-ocean ice is a **feature**: boats run fast on ice, so icy approaches are high-speed lanes (loved by rowers and pirates alike). -- Base generators: AcidIsland-style ocean/ocean-floor (no acid) for both the overworld and the dedicated Nether-environment interstice world. No End. -- Unowned BentoBox islands with protection flags per **security band** (EVE model: safe → less safe → anarchic); no block place/break normally; island name (Elite-style procedural) announced on entry. -- Reference dimensions from AcidIsland framing: ~1000 range, ~400 protection radius, border shown at protection edge. Islands never within sight of each other. -- **Tech level** per island (default scale 1–7), seeded, correlated with island type (industrial skews high, agricultural low, with variance — "Advanced Agricultural" exists). Shown in the entry announcement and chart data: *"Esedaxe — Advanced Industrial (Tech 6), Safe."* Tech gates **shops only, never docking or use**: boat ranks sold up to TL × 3 (TL7 = all twenty), expanders sold only at top-tech islands. Tech also modulates prices — high-tech sells finished goods cheap and buys raw dear, low-tech the inverse — so the best routes are tech *differentials* and route planning reads type × tech × security off the chart. Starter cluster guarantees at least one ~TL3 island. Crafting deliberately bypasses shop tech gates: shop-with-tech is the money route to a big boat, finding rare wood is the exploration route. - -### Travel -- Border is **passable**; rowing between/past islands is free. Reaching the border in a boat offers the warp dialog (Paper 1.21.6 dialog API); rowing through does nothing special. -- **Route graph** with seeded per-edge costs; default = Euclidean distance × multiplier; per-edge overrides allow cheap lanes and expensive frontiers without regenerating the world ("warp lanes" is the in-fiction excuse for cost/distance mismatch). -- Fuel value table (wood < coal < lava bucket); lava's non-stackability is deliberate tension (high energy, full slot). Fuel consumed from hold only. -- Warp: dismount → teleport → re-seat (proven AcidIsland `/ai` pattern); arrival just inside the destination border **on the bearing of the origin island**; nausea + blindness + configurable minor damage (lore: warping hurts; gates the under-equipped); portal particles + sound (no real portal blocks needed). -- **Charting:** warp dialog lists charted islands only. Chart by physically entering an island's range (free, slow) or buying chart data (fast, money sink — post-MVP good). Starter cluster pre-charted. Charting doubles as the discovery layer over lazy generation. - -### Interstice (warp failure) -- Configurable failure chance; on failure the player + boat land on interstice water **partway along the route**, with 1–3 Ghasts spawned. -- **Re-engaging to the original destination is always free** — the fuel was spent at engagement; failure is a detour, never a fuel loss. This rule is what makes stranding impossible; do not remove it. -- Ghast-vs-boat combat is intentionally survivable (fireballs can be batted back). - -### Cargo & progression -- Start kit: boat + 1 trading bundle (a bundle = one stack's worth of capacity). Up to 3 bundles → chest boat → lore-renamed shulker **cargo expanders**, purchased only, price ~doubling per unit, configurable cap. Long expansion runway is intentional: maxed boats carry fortunes and are worth defending or attacking. -- Boat owner recorded in entity PDC from day one (theft, persistence, chunk-unload questions all hang off this). - -### Economy -- Everything in Minecraft is tradeable. **BlueBook** (`~/git/bluebook`) supplies base prices; modifiers from island type, biome, security band, and per-island stock/price drift persisted via the BentoBox database. Players may sell found/crafted items through the same market. -- Margins scale with danger: the best prices live in the risky bands. - -### Salvage & free trading (decided 2026-08-03) -The scavenger/farmer/pirate has loot and nowhere to put it. Ports buy almost -anything, but **shallowly**: price decays as you sell in, recovers over time, so -a stack of diamonds is several ports' worth of business rather than one payday. -This is the existing stock-drift machinery (`IslandDataManager`, `TWIslandData`, -`driftScale`/`stockDecayPerHour`) applied to found goods — the rate limit is -what stops an infinite farm faucet outcompeting the trade game. -- **Drift must be value-weighted, not unit-weighted.** Ten diamonds should move - a port's prices far more than ten wheat; `adjustStock` currently counts units. - Model it as the trader's **purse** — coins spent on a category, replenishing — - which is also how players naturally describe it. -- **Salvage gets its own stock bucket** (not `MISC`) and a steeper discount than - proper trade goods. Otherwise dumping junk craters the legitimate price - gradient in that category, which is a griefing vector on a small server, and - piracy out-earns honest trading. -- **Tech level gates what a port recognises**: a TL1 hamlet has no use for - netherite. Loot therefore has a *destination*, which is a route. -- **NBT is preserved and priced.** A worn bow, a Potion of Strength II and a - Silk Touch pick are not their base materials. `PriceEngine` already applies - durability and can weigh enchantments (`getEnchantmentValue`, currently - unwired); potions need `PotionMeta` handling. The hold (`Map`) - and the `Material`-typed `MarketService` signatures are what throw NBT away — - both must carry ItemStacks. Breaking data-model change: wipe on deploy. -- **The hold becomes two-way for player-loaded goods.** This narrows the - load-bearing "cargo leaves only by sale or destruction" rule, which now - applies to **trader-bought cargo only** — that is what preserves cargo - commitment and keeps the hold from being an off-market transfer channel. - Player-loaded salvage can be withdrawn. Track provenance per stack and never - merge across it, or the flag becomes ambiguous. -- **Sold goods resurface for players to buy**, but only notable items (enchanted, - named, above a value threshold), a slot or two per port, with a TTL — and they - surface at a *different* island than the one they were sold at ("the trader - shipped it on"). Unpredictable destination is what stops alt-account - laundering turning port shelves into a global auction house. -- Price discovery is a **logbook**, not an oracle: prices cannot be shown for - ports you have not visited, and the chart remembers only what you saw and how - long ago (Elite/TradeWars precedent). At the counter, show the live price *and - the depth* — "this port will take ~40 more before the price falls" — which is - the number the player actually needs. Purchasable market reports at high-tech - ports, radius and freshness scaling with tech level, give TL a role beyond - goods; plaza rumours are the cheap flavour version. - -### Contraband & customs -- Master **config gate disables all illegal-goods mechanics** for family servers; on by default. -- Contraband: **sugar** (never referred to as drugs, in code, config, or locale). Villager "passengers" in boats sellable at less-safe islands only (how the player boats a villager is their problem — emergent by design). -- **Scan on every entry** to island space — rowing or warping in alike. Chance scales with security band (high-sec scans often; anarchic never). Per-island scan cooldown prevents re-entry scumming. -- Detection starts a **chase, not an instant fine**: "customs patrol dispatched," police launch from the island, and the ~400 blocks of water are the smuggler's decision window — flee across the border, fight, or **jettison** the contraband (dumped items float, lootable by anyone: emergent piracy). Caught = police land a hit or close to proximity while contraband is aboard → confiscation + fine + rep loss. -- Fleeing a detection **flags you at that island** for a cooldown: re-entry skips the roll and launches police immediately. - -### Reputation, police, bounties -- Single global score, named bands (working proposal: Upstanding / Clean / Offender / Wanted / Fugitive), all thresholds and penalties configurable. Villager gossip API hooked for organic price penalties on the offended island. -- Recovery, three speeds: slow decay toward zero during clean play (crimes shadow you for sessions, not minutes); paying fines (capped — money buys you out of *Wanted*, not into virtue); positive acts (contracts/donations, post-MVP) above zero. Positive rep must buy something (better prices, lower scan odds) so *Upstanding* is a pursued status. -- **Wanted**: PvP damage override anywhere, growing bounty, police response at civilized islands. **Fugitive**: shoot-on-sight and barred from safe-island trading (see principle 4). Killing a wanted player pays the bounty with no rep penalty — bounty hunting is lawful work. -- **Bounty nameplate** (config option): a wanted player's bounty is displayed with their name over their head — a target for hunters, a badge of infamy pirates will cultivate. Implementation: per-player scoreboard team suffix, only applied when bounty > 0 (clean players show nothing); PlaceholderAPI placeholder exposed so nametag plugins (TAB etc.) can render it themselves instead of conflicting. Note: sneaking hides nameplates on foot, but players in boats cannot sneak — traveling pirates are always visible. -- Police mobs by mobility, not just element: **Phantoms pursue** (fast; combustion cancelled or day patrols burn at dawn), **Guardians hold the sea perimeter** (slow swimmers who will never catch a boat — they are area denial around the island, not chasers), **Golems** handle anyone who lands. All PDC-tagged, ticking `setTarget` reassertion, **zero drops** (or wanted players become a crime-powered iron farm). Pursue within the protection zone, break off when the target crosses the visible border (escape you can see), despawn beyond island range or on unload. -- Per-band knobs: scan chance **and** response size — a mid-band island may scan rarely but respond hard. Route planning with contraband should be map-reading skill. -- Anti-bait guard for innocent-kill penalties: direct kills only; prior damage from the victim exempts. Log edge cases, tune later; do not over-engineer pre-MVP. - -## Proposed but not yet adopted (needs Ben's sign-off) -- Concrete reputation numbers: −1000..+1000 scale; hit villager −5, kill villager −25, kill police −15, kill innocent −100, scan-caught −30 + fine, slave sale −20; decay ~+1 per 15 min clean active play; band thresholds +250 / 0 / −200 / −500. -- **Regional density = civilization**: occupied-position density higher near spawn, thinning outward, with security band correlated to local density (clusters civilized, isolated islands anarchic). Makes the map itself communicate risk. -- Wanted players suffer a higher warp-failure chance (crime feeds the interstice ambush meta). -- Interstice is a **shared** world (enabling warp-space interdiction/ambush — very Elite) rather than instanced pockets. -- Post-MVP: stall rental on NPC islands for the player market (goods join the island's trade pool; rent as sink; stall location matters via the route graph); insurance (hull/expanders insurable, cargo never); "last seen" wanted-player intel sold at trade posts; Elder Guardian as the top-tier wanted response (its fatigue aura hits innocents — `EntityPotionEffectEvent` cancellation is possible but fights the mob's design, hence demoted from standard police). - -- **Missions as NPC requisitions** (Ben, 2026-08-03 — the intended shape for the - whole missions system, not just delivery jobs): a port posts a standing order — - *"Baker's Reach wants 200 wheat at 40/unit, expires in 3 days"* — seeded from - the island's type, tech level and current stock, on a board at the plaza. The - player fills it in whole or in part, at a price fixed above the drifting market - rate, and the reward is the premium plus reputation. - This is deliberately the **5% of an EVE order book that fits a 20-player - server**: it gives the farmer a goal and the scavenger a destination, and it - needs no escrow, no player-placed orders and no offline value transfer — the - counterparty is the island. Player-placed buy orders were considered and - rejected for now (an order book with three participants is a waiting room, not - a market, and "collect your goods from the trader" implies a warehouse, which - breaks the boat-is-the-hold rule). Revisit only if a server ever has the - population to support it, and then at spawn only. - Generalises to every mission type: bounty contracts, passenger runs, courier - jobs and salvage recovery are all "a board posts a job, you sail, you're paid." - -## Open questions -1. **Name — RESOLVED: TradeWinds** (one word, house style like BSkyBlock/AcidIsland; repo `TradeWinds` under BentoBoxWorld). "Trade Wars MC" rejected — the TradeWars 2002 mark is still live, and "Wars" mis-signals a trading game; Sandlot's Tradewinds series is defunct and the phrase is generic. -2. **Poseidon internals:** can a radial positional mask multiply into its existing density tweaks, or is it cleaner to lift its sea-level/decoration handling into a fresh generator with the mask native? (First real Stage 1 task.) -3. **Dock placement:** deterministic terraformed shelf at a fixed bearing (predictable, teachable) vs. jigsaw terrain-adaptive placement (organic) — current lean: deterministic dock, adaptive decorations. -4. **Border interaction:** "click the wall" needs packet-level handling of client-side blocks; proximity trigger + action-bar prompt is cheaper and reads the same. Pick one; Border must run in passable/visual mode regardless. -5. **Boat loss rules:** what drops on boat destruction / player death? Full expander drop makes trader-piracy the apex game (very EVE) but is brutal; insurance is the counterweight. Undecided. -6. **Interstice PvP rules** if shared: can hunters lawfully take non-wanted players there, or is interdiction itself a rep crime? -7. **Scan/flag tuning:** scan cooldown length, flee-flag duration, "caught" proximity radius. -8. **Galaxies architecture:** multiple configured addon instances (AcidIsland/BSkyBlock coexistence model) vs. a world manager inside one addon. Deferred, but keep the world reference abstracted from day one. -9. **Villager shoreline safety:** golems can't path onto water; keep villagers inland behind the docks so drive-by boat raids don't trivialize the police, or add a ranged deterrent. -10. **Reputation numeric tuning** (see proposal above) and whether reputation is global or per-galaxy once galaxies exist. diff --git a/tradewinds-dev-plan.md b/tradewinds-dev-plan.md deleted file mode 100644 index f9cf279..0000000 --- a/tradewinds-dev-plan.md +++ /dev/null @@ -1,158 +0,0 @@ -# TradeWinds — Development Plan -### Staged implementation for Claude Code - -Each stage produces a testable, playable increment. Stages are sized so a stage is one or a few focused Claude Code sessions, with clear acceptance criteria Claude Code can verify (unit tests where deterministic, manual test checklist where not). Reference code lives in `~/git`: BentoBox core, AcidIsland, Poseidon, Boxed, Border, BlueBook. - -**Before Stage 0:** write `TRADEWINDS_SPEC.md` in the repo root (same approach as GUSHER_SPEC.md) capturing the full design, plus a `CLAUDE.md` pointing at the reference repos, build commands, and coding conventions. Every stage below assumes Claude Code reads the spec first. - ---- - -## Stage 0 — Addon scaffold -**Goal:** an installable gamemode addon with an empty ocean world. - -- Maven project extending `GameModeAddon`; config, locale files, `/tw` player and admin commands. -- Register the overworld plus a dedicated Nether-environment world (interstice) — both using an AcidIsland-style generator (ocean over ocean floor, no acid), Nether variant styled accordingly. -- No End world. Config skeleton for all later knobs. - -**Accept:** addon loads alongside other gamemodes; `/tw` teleports to spawn in open ocean; interstice world exists and is inaccessible to players. - -## Stage 1 — Seeded galaxy: placement, biomes, names -**Goal:** deterministic island layout from one shareable seed. - -- Placement engine: seed → random sparse island positions (rejection sampling against minimum separation via the spatial-hash island grid), each with type (agricultural, industrial, …), security band, biome, and range/protection radius. -- Elite-BBC-style procedural name generator (token-pair digraphs), seeded. -- Chunk generator derived from Poseidon: terrain noise × radial falloff mask at island centers; pure ocean elsewhere; per-column biome from island type. Guaranteed starter cluster density near spawn. -- On first generation of an island's center chunk: register an unowned BentoBox island with flags per security band; announce name on entry. - -**Accept:** unit tests prove same seed → identical positions/types/names across runs; rowing out reveals distinct biomed islands with no visible pop-in; islands persist in the database. - -## Stage 2 — Island content -**Goal:** islands worth visiting. - -- Deterministic dock/market shelf terraformed at a fixed bearing from center; blueprint sets per type and biome pasted there. -- Jigsaw/structure-command placement for decorative structures (Boxed pattern). -- Villager population with professions per island type; resident iron golems. - -**Accept:** every generated island has a dock, market, villagers, and golems appropriate to its type and biome. - -## Stage 3 — Travel: warp, fuel, charting -**Goal:** the core movement loop. - -- Border addon integration in passable mode; proximity trigger at the border while boated opens the warp dialog (Paper 1.21.6 dialog API): charted destinations, fuel cost each, unreachable grayed out. -- Route graph: seeded edge costs, default Euclidean × multiplier, per-edge override config. -- Fuel item value table (wood, coal, lava bucket, …); consumption from trading bundle / boat hold only. -- Warp execution: dismount, cross-position teleport, re-seat in boat (AcidIsland `/ai` pattern); arrival just inside the destination border on the origin bearing; nausea + blindness + configurable minor damage; portal particles and sound. -- Charting: islands enter a player's chart by rowing into their range or (later) buying chart data; dialog lists charted islands only. Starter cluster pre-charted. - -**Accept:** full row-out/warp-back loop works including cross-distance re-seating; fuel deducts correctly; uncharted islands never appear in the dialog; costs match the seeded graph. - -## Stage 4 — Economy and cargo -**Goal:** buying low and selling high — the game becomes a game here. - -- BlueBook integration for base prices; per-island modifiers from type, biome, security band; per-island stock and price drift persisted via BentoBox database. -- Trade GUI at the dock transacting **only** against trading bundles and boat hold. -- Cargo progression: start kit (boat + 1 bundle); up to 3 bundles; chest boat; purchasable lore-renamed shulker "cargo expanders" up to a configurable cap, price doubling per unit. Boat owner tag in PDC. -- Vault economy hookup; starter balance config. - -**Accept:** profitable routes exist and differ by island type; items outside bundles/hold cannot be sold; expansion pricing and caps enforced; market state survives restarts. - -## Stage 5 — The interstice (warp failure) -**Goal:** risk on the fast route. - -- Configurable warp failure chance; on failure, place player + boat on interstice water partway along the route; spawn 1–3 Ghasts nearby. -- Re-engaging warp from the interstice to the original destination is always free (fuel was already spent). -- Interstice mob/PvP rules; cleanup of stale interstice entities. - -**Accept:** failure rate matches config; stranding is impossible (re-engage always available); ghast fight is survivable in a boat. - -## Stage 6 — Crime: reputation, customs, police -**Goal:** the law layer. Largest stage — split into three sessions. - -**6a — Reputation core:** global score with named bands and configurable thresholds; listeners for villager harm, golem kills, player kills (wanted vs innocent), contraband sales; slow clean-play decay; fine payment at civilized islands; villager gossip hook for price penalties. - -**6b — Customs and contraband:** contraband list (default: sugar; villager "passengers" flagged at less-safe islands only) behind a master config gate for family servers. Scan roll on any entry to island space (row or warp), chance per security band; detection message; caught = police reach proximity/land a hit while contraband aboard → confiscation, fine, rep loss; jettisoned cargo clears you; fleeing a scan flags you at that island for a cooldown. - -**6c — Police and bounties:** police spawner per security band — Golems (land), Guardians (sea perimeter), Phantoms (pursuit, combustion cancelled); PDC-tagged, zero drops, ticking `setTarget` task; pursue within protection zone, break off at the border, despawn beyond island range or on island unload. Wanted players: PvP damage override anywhere, bounty ledger growing per crime, paid to the killer with no rep penalty; Fugitive band barred from safe-island trading. Bounty **nameplate display** (config-gated): bounty shown with the player's name via per-player scoreboard team suffix, absent at zero bounty; expose a PlaceholderAPI placeholder so servers running TAB/nametag plugins can integrate instead of conflicting. - -**Accept:** each rep transition triggers the right consequences; smuggler chase (scan → flee/fight/jettison) plays out correctly; police never drop loot and never leak entities; bounty pays exactly once. - -## Stage 7 — Player islands: wild-islet claiming -**Goal:** ownership and faction bases. Decided 2026-08-04: players claim -**existing wild islets** — no blueprints, no pasting, no random placement. The -land is already there; claiming registers it. Islets never overlap trading -islands (generator clearance), and any band is claimable. First claimed, first -owned: pillaged, wrecked or built-on makes no difference. Two sessions. - -**7a — Seafarer ranks & the claim:** -- **Rank service**: rank = charted-island count against a config threshold - table; names in the locale (`tradewinds.rank.`). Default table - (bottom lifted above the pre-charted starter cluster of 5): Deck Hand 0, - Cabin Scout 6, Coast Finder 8, Bay Explorer 10, Island Hopper 12, Reef - Runner 15, Wave Master 17, Chart Maker 20, Storm Navigator 25, Archipelago - Ace 30, Tide Captain 36, Horizon Hunter 42, Sea Sentinel 49, Salty - Commodore 56, Ocean Overlord 64, Pirate Pathfinder 72, Grand Admiral 81, - Globe Voyager 90, Mythic Mariner 100. (Calibration: ~36 ≈ explored past - Frontier, ~100 ≈ deep Anarchic.) -- **Leaderboard + placeholders**: cached top-10 by charted count, updated on - chart events; PlaceholderAPI placeholders for own rank and the top list. - (Money cannot rank — Vault has no top-N and Bank hides value.) -- **Claim command**: standing on an unclaimed wild islet, rank ≥ config gate - (default Tide Captain) and balance ≥ config price (default 150,000 — above - the Pale Oak Chest Boat's 110,250, so boats come first). Registers a real - BentoBox island centred on the islet, protection range from islet radius + - margin, default flags (players and admins customise after). Bank and team - machinery attach on their own; max team size is BentoBox perms — admin turf. -- **Respawn**: BentoBox respawn-on-island default; islandless players respawn - at the spawn port. - -**7b — Living with a base:** (BUILT 2026-08-05 — see PROGRESS) -- **`/tw go` shows the way home, never takes you.** Toggle: while on, a - persistent homing indicator (chart-hologram home marker + nav bar course) — - coarse 8-point direction by day, exact bearing and distance by night (read - the stars). Home renders on the Star Chart for all members. -- **Member-only warp node**: the claimed islet joins its members' warp - dialogs (pinned entry, normal fuel cost); warping OUT triggers at the - claimed island's protection border like a port's ring. Non-members never - see it. No teleporting anywhere — the warp is the only fast route, exactly - as between ports. -- **Unclaim**: owner-only command, admin-unregister semantics — blocks stay - exactly as left, islet becomes claimable again, no refund. Refuses while - the team has members: kicking everyone out first is the intended pain. -- **Ship's compass** on the outfitter shelf: vanilla lodestone-bound compass - to the base. Physical, lossable, giftable. - -**Accept:** claim gate enforces rank and price; claimed islet protects and -manages like an AcidIsland island; members (and only members) can warp to and -from it on normal fuel; `/tw go` guides without teleporting; unclaim leaves -every block and reopens the claim; rank names all come from the locale. - -## Stage 7.5 — Salvage economy & free trading -Scavengers, farmers and pirates need a market; the hold needs to carry NBT and -work in both directions. Planned in full in **`tradewinds-salvage-plan.md`** -(normative): trader's purse (value-weighted drift) → price coverage + salvage -bucket → tech gating → NBT hold → two-way hold → price discovery (depth at the -counter, chart logbook, market reports) → resale shelves. - -## Stage 8 — Post-MVP backlog (seeded, unscheduled) -- **Re-enable the price logbook + harbour reports** (`economy.price-logbook-enabled`, - built but parked 2026-08-03): fix the absolute-price "best" column, add category - tab-complete, make the report purchase visibly land. See the status note in - `tradewinds-salvage-plan.md`. -- Player market stalls rentable on NPC islands (goods enter that island's trade pool; rent as money sink). -- Chart data as a purchasable good; wanted-player "last seen" intel for bounty hunters. -- Boat/expander insurance (cargo never insurable); boat destruction and death-drop rules tuning. -- **Missions, as NPC requisition boards** — "Baker's Reach wants 200 wheat at - 40/unit, 3 days", posted at the plaza, seeded from island type/tech/stock, paid - above market plus reputation. This is the intended pattern for *all* mission - types (delivery, bounty, passenger, salvage): the island is the counterparty, - so no escrow or player order book is needed. See the design-decisions entry. -- Multiple galaxies as additional worlds; inter-galaxy warp as endgame. -- Elder Guardian boss response for the most-wanted tier. - ---- - -### Working notes for Claude Code sessions -- Keep everything downstream of the seed **pure and unit-testable**: placement, names, biomes, route costs should be plain functions of (seed, position) with no Bukkit dependencies, tested headlessly. -- Isolate Bukkit-facing code behind small interfaces so listeners and tasks stay thin. -- Every config value in `config.yml` from the stage it's introduced — no hardcoded gameplay numbers. -- Manual test checklist per stage lives in `TESTING.md`; Claude Code updates it as features land. diff --git a/tradewinds-hold-plan.md b/tradewinds-hold-plan.md deleted file mode 100644 index 63184d9..0000000 --- a/tradewinds-hold-plan.md +++ /dev/null @@ -1,185 +0,0 @@ -# TradeWinds — Virtual Hold & Boat Mechanics: Implementation Plan -*For Claude Code. Merged from Ben's "# Boat mechanics.md" (authoritative on conflicts) and the earlier virtual-hold plan. Server-authoritative holds in the database; no real containers ever hold cargo. Clean-slate build, no migration.* - -## Core model - -**One Boat Rule: a player has exactly one boat, or none.** The hold is conceptually the *player's* cargo inventory; the boat they possess only sets its size. Upgrading never moves items — the slot count grows and existing contents stay put. - -Storage is database-backed (BentoBox Database API); the boat item/entity is a key. While a player owns a boat, the hold is keyed to the player; when the boat becomes a dropped item, the hold contents travel with the item (item-keyed record with the hold UUID in the ItemStack PDC) until picked up or expired. - -## Boat ranks (defaults; `config.yml` map `boat-material → slots`) - -| Rank | Boat | Slots | | Rank | Boat | Slots | -|---|---|---|---|---|---|---| -| 1 | Bamboo Raft | 2 | | 11 | Dark Oak Boat | 12 | -| 2 | Oak Boat | 3 | | 12 | Jungle Chest Boat | 13 | -| 3 | Spruce Boat | 4 | | 13 | Mangrove Boat | 14 | -| 4 | Bamboo Chest Raft | 5 | | 14 | Acacia Chest Boat | 15 | -| 5 | Birch Boat | 6 | | 15 | Cherry Boat | 16 | -| 6 | Oak Chest Boat | 7 | | 16 | Dark Oak Chest Boat | 17 | -| 7 | Jungle Boat | 8 | | 17 | Pale Oak Boat | 18 | -| 8 | Spruce Chest Boat | 9 | | 18 | Mangrove Chest Boat | 19 | -| 9 | Acacia Boat | 10 | | 19 | Cherry Chest Boat | 20 | -| 10 | Birch Chest Boat | 11 | | 20 | Pale Oak Chest Boat | 21 | - -## Expanders - -- Install **only in a Pale Oak Chest Boat** (the top rank). An installed expander **occupies one C slot**; clicking it opens a nested inventory panel — same style, own TNT destroy slot, **no fuel row**. -- Proposed defaults *(veto welcome)*: nested inventory is **21 slots** (net +20 per expander); expanders cannot contain other expanders or any container item; an expander can be dragged to TNT **only when empty**; nested inventories are openable only while the expander sits in a Pale Oak Chest Boat's hold (an expander that ends up elsewhere via drop/pickup rides along as inert cargo, contents preserved, until installed again or destroyed); sold at shops only, and only at **top-tech (TL7) islands** — the endgame sink has a home port. -- No installed-count cap needed: the 21 C slots are the natural ceiling, and doubling prices (below) make the tail aspirational. A fully expanded ship approaches ~440 slots and a nine-figure spend — the "guard it with your life" endgame. - -## Pricing (defaults; formulas in config) - -- **Boats — quadratic:** `price = 25 × slots²`. Anchors: Bamboo Raft 100, Birch 900, Jungle 1,600, Mangrove 4,900, Pale Oak 8,100, Cherry Chest 10,000, Pale Oak Chest 11,025. Early upgrades are pocket change; the top rungs cost real trading profit but stay reachable — correct, since boats are also craftable and the shop is a convenience for players who haven't found the rarer groves. -- **Expanders — doubling:** `price = 5,000 × 2^(installed)`. First 5,000; fifth 80,000; eighth 640,000; tenth 2.56M. The curve *is* the endgame money sink — no cap required, the wallet is the cap. - -## Hold GUI (BentoBox Panel API) - -Six rows: - -``` -X T X X X X X X X -X C C C C C C C X -X C C C C C C C X -X C C C C C C C X -X X X X X X X X X -X F F F F F F F X -``` - -- **T — TNT destroy slot.** Lore: "Drag cargo here to destroy them." Dragging cargo onto it removes the items from the game (destroyed, NOT dropped into the world). Fuel dragged here is never destroyed. -- **C — cargo slots**, up to 21, unlocked left-to-right/top-down to the boat's capacity; locked slots render as border panes (X). -- **F — fuel slots**, 7, shown as pale-red glass panes with lore "Fuel Slot" when empty. Fuel dropped anywhere in the GUI routes to a fuel slot and stacks where possible. Fuel is freely added, moved, and **removed** — fuel does not compete with cargo and is not subject to one-way rules. Lava buckets occupy a fuel slot each; on consumption the lava is burned and the empty bucket returned. -- **X — light-blue glass border**, no lore. - -**Contents auto-consolidate:** items are always stored in optimal stacks, never spread — slot usage is always minimal. - -### Removal rules -Cargo can leave the hold only by being **sold** or **destroyed** (TNT slot). Transfers out to player inventory, drops, hotkeys, shift-clicks, and hopper-adjacent tricks all must fail (adversarial test list required). - -### Insertion rules -**Any item may be added to the hold, one-way**, except pouches, bundles, shulker boxes, or any other container item (they would expand effective hold size). Sole exception: official Cargo Expander items, in Pale Oak Chest Boats only. If an island won't buy what you inserted, it simply wastes space until you destroy it. Customs stamping is **removed entirely** — capacity and the stock/demand pools carry the balance. - -## Opening the hold -- Sitting in the boat + right-click — chest boat or not. -- Sneak + right-click the boat entity. -- Right-click the boat item in your inventory. - -## Obtaining and upgrading boats - -Three routes: **shop**, **craft**, **pick up a dropped boat item**. - -### Shop -- Shops list only boats **bigger** than your current boat, priced per the quadratic formula above, and only up to the island's **tech level**: ranks sold ≤ TL × 3 on a 1–7 scale (config), so top boats exist only at high-tech ports. Tech gates shops only — never docking, riding, or crafting. -- Purchase replaces your current boat — **the replaced boat is destroyed**; you are buying an upgrade, not a second boat. (Grants a boat if you had none.) Contents persist untouched — capacity simply increases. Example: Pale Oak Boat (18 slots, full of granite) upgraded to Cherry Chest Boat (20) → same 18 stacks of granite, 2 new empty slots. - -### Crafting -- Same replacement semantics. Crafting a boat **smaller** than your current boat is blocked (craft event cancelled with a locale message). -- A player may add a chest to their current boat at any time (vanilla boat+chest recipe) — an in-place upgrade to the chest variant's capacity. - -### Dropped boat items -Boat items exist when thrown, dropped on death, or when a boat entity is broken. The hold contents travel with the item. - -1. **TTL:** every dropped boat item gets an expiry timestamp **stored in the database** (survives restarts/crashes). A periodic task removes overdue items by UUID, along with their contents. -2. Pickup, item **bigger** than your boat: it immediately replaces your boat — **the replaced boat is destroyed** (upgrade semantics, same as the shop); your capacity grows; your existing contents remain and the captured contents merge in. -3. Pickup, item **smaller or equal**: you keep your boat; contents transfer from the captured item **most-valuable-first** (BlueBook base price as the value oracle) until your hold is full. -4. If your hold fills mid-transfer, you're told the transfer is partial; the remainder stays in the dropped item (still under TTL) for anyone — including you, after destroying or selling to make room. -5. **Sanctioned use:** dropping a loaded boat so another player can pick it up is the intended way to hand goods over. Not an exploit. -6. Pickup with **no current boat**: the item becomes your boat with its contents intact *(assumption — confirm)*. - -### Edge cases (from Ben's doc) -- If the dropped item entity is destroyed by external means (another plugin, etc.), the inventory is lost — accept and clean the DB record. -- Boats must never be destroyed **except by lava**: trident hits, collisions, and other breakage always yield the item. -- On player death, the boat drops as an item with contents (except lava deaths). **`keepInventory` servers/regions:** the player simply keeps their boat and hold. **DeathChest servers:** the boat goes into the death chest; the player must buy a boat and fuel and physically travel there (no teleporting per DeathChest rules); on retrieving their old boat, normal pickup/upgrade rules apply and the recovery boat — being smaller — is the one removed. -- A mob (e.g. zombie) picking up the boat item: **works for free under this model** — contents are DB-keyed via the ItemStack's PDC, which survives mob pickup; kill the zombie, the item drops, hold intact. TTL handling while mob-held: pause or extend TTL while an entity holds it (recommend: pause). -- Overfull situations (e.g. capacity shrinks by admin action): player must choose what to keep; open the GUI in triage mode where excess must be destroyed before normal use resumes. - -## Data model - -``` -PlayerHold (DataObject) DroppedBoat (DataObject) - uniqueId : player UUID uniqueId : hold/item UUID (in item PDC) - boat : material or none boat : material - contents : [{material, amt}] contents : [{material, amt}] - fuel : [{material, amt}] fuel : [{material, amt}] - expiresAt : timestamp (TTL) -``` -Ownership transitions (drop → pickup → replace) are atomic DB moves between the two objects. Duplicating a key item cannot duplicate cargo — contents exist once, server-side. - -## Trade & customs integration -- Plaza trading transacts against the player's hold — with One Boat there is never ambiguity. -- Customs scans the player's hold (and the dropped-boat record if they're carrying a boat item — not possible under One Boat, but guard anyway). -- Smuggler evidence-dumping = destroying contraband via the TNT slot before police close. -- Fuel checks (warp dialog, low-fuel warnings) read the fuel slots only. - -## Resolved rulings (this revision) -1. Expanders **kept**, Pale-Oak-Chest-only, nested-inventory model (see Expanders). -2. Boat pricing **quadratic**, expanders **doubling** (see Pricing) — proposed defaults awaiting veto. -3. Replaced boats are **destroyed** — purchases and bigger-boat pickups are upgrades. -4. Customs stamping **removed entirely**. -5. `keepInventory` / DeathChest behavior defined (see edge cases). -6. Destroy-only jettison confirmed. - -Remaining proposed-not-ruled defaults, all in the Expanders section: nested size 21, no nesting, empty-before-destroy, openable only while installed in a Pale Oak Chest Boat, sold at industrial islands. - -## Rulings 2026-08-01 (Ben, in session) - -1. Pouches/bundles fully retired — the boat is the only hold sizer. -2. **Start kit: Oak Boat (rank 2, 3 slots)**; charity/destitution grants a **Bamboo Raft (rank 1)**. -3. Expander home port: **TL7 islands regardless of type** (supersedes "industrial islands" above). -4. Tech price modulation: **±3% per TL step from TL4** (config `economy.tech-price-step`), finished categories (METALS, FOOD) cheaper / raw (ORES, CROPS, WOOD, FISH, STONE) dearer as TL rises. -5. Jettison is **destroy-only** (TNT slot) — a last-ditch option when you cannot beat the police or escape. Nothing floats. -6. Higher-tech plazas gain amenities: all ports have the galley (workbench + campfire); TL3+ furnace + stonecutter, TL4+ smithing table + grindstone, TL5+ brewing stand + cauldron, TL6+ anvil, TL7 enchanting table with bookshelves. -7. **Bigger-boat pickup is a SWAP, not a destruction** (supersedes "the replaced boat is destroyed" for the PICKUP path only - shop purchases remain trade-ins): you take the bigger hull keeping your own cargo; the wreck's cargo merges in most-valuable-first; the overflow goes over the side in your OLD boat, which floats (tagged, fresh TTL) where the wreck was. The old hull can always take the overflow - your kept cargo displaced at most that many slots - so **nothing is ever lost to the sea**. - -## Acceptance tests -- One Boat invariant holds through every path: shop, craft, pickup, death, admin gives. -- Upgrade (shop/craft/chest-add) preserves contents exactly; smaller purchases/crafts impossible. -- Dropped-item TTL fires across a server restart; mob-held items survive with contents. -- No GUI interaction sequence extracts cargo to player inventory; fuel moves freely both ways; TNT destroys cargo, never fuel. -- Pickup transfer is most-valuable-first, partial transfers leave the remainder intact and claimable. -- Death always yields the boat item with contents (except lava); trident/collision yields the item, never destruction. -- Expanders: installable only in Pale Oak Chest Boats; nested GUI never accepts containers or expanders; TNT refuses a non-empty expander; nth-expander price = base × 2ⁿ; expander riding in a smaller boat after pickup is inert but its contents survive round-trips. -- Tech gating: a TL n shop never lists boats above rank 3n or (below TL7) expanders; crafting is unaffected by tech level. - -## Rulings 2026-08-02 (abandonment, capture, proximity) — Ben, in session - -**Finding a hull (revised 2026-08-02, supersedes the smaller-boat rule):** -picking up OR boarding a boat that is not yours always opens a dialog whose -options depend on how far away your own boat is: -1. **Take the boat** - it becomes yours; your own becomes an unowned OLD BOAT - where it lies. Always available, whatever the sizes. -2. **Take only the cargo** - moved into your own hold, hull left behind. - Offered ONLY when your own boat is within `boats.cargo-transfer-range` - (default 200 blocks) and the hull actually carries something: cargo never - crosses the ocean on its own. -3. **Leave it alone.** -A player with no boat at all claims the hull outright, no dialog - there is -nothing to lose. - -**Trading proximity:** you cannot trade at an island unless you have a boat -within that island's space (BentoBox island check). No boat there, no market. - -**Abandonment (boat left unattended):** -1. `/tw chart` shows your active BOAT wherever you are in the world, like the - DOCK marker. -2. Unattended inside island space: protected — no other player may board it, - and it cannot be broken, even by mobs. Applies to every band except - ANARCHIC. -3. Unattended outside island space: fair game for capture. Still technically - yours, but **it cannot be used for trading** (it is not in island space). - Board a different boat and the old one is no longer your main boat: the - chart shows it as OLD BOAT until another player takes it or it is - destroyed. This is the death-recovery route: leave the boat at a dock, die - ashore, respawn at spawn, take a bamboo raft, row back, board the old boat. -4. A player who boards an unattended unprotected boat **acquires it and - everything in it**. Their previous boat becomes OLD BOAT on their chart. -5. Riding as a *passenger* never takes over a boat. - -**Logging off:** -1. In a protected area: the boat is protected while you are offline. -2. Outside protection, or on an ANARCHIC island: the boat stays in the world - and can be taken. On next login the player is told it was taken/stolen; if - they have no boat and are in water, they are given a bamboo raft to paddle - with. -3. Players who want certainty must dismount, break the boat and carry it in - their inventory before logging out. diff --git a/tradewinds-interstice-plan.md b/tradewinds-interstice-plan.md deleted file mode 100644 index f97c01f..0000000 --- a/tradewinds-interstice-plan.md +++ /dev/null @@ -1,178 +0,0 @@ -# TradeWinds — Interstice Resources Plan - -*Drafted 2026-08-05 from design discussion with Ben. Normative for nether-item -sourcing and portal behaviour. Status: **BUILT** (same day) — N1, N2 and the -booty half of N3 shipped; deferred: magma cubes, secondhand-shelf seeding, -price spot-checks after play. See PROGRESS.md for implementation notes.* - -## Goal - -Nether-exclusive items must exist so beacons, potions and the rest of -late-game crafting are reachable — without an End world (never), without -breaking "entry to the interstice is by accident", and without new economy -plumbing (every target item already has a base price; the salvage loop -absorbs everything). - -The interstice stays what it is — a hostile dark sea you are dropped into — -but a dropped sailor now has a reason to look around before taking the free -re-engage home. **Warp failure changes from punishment to opportunity.** - -## Entry model (decided) - -**Consolation-prize only.** Resources are reachable only when a failed warp -puts you there. The owed re-engage never expires, so you may linger, gather -and fight as long as you dare, then take the free jump. No deliberate-entry -mechanic ships with this plan; see Portals below for the gated future option. - -Knock-on to check at implementation: `/twadmin warpfail` becomes the admin -door for testing all of this, and players will discover that Anarchic-band -warps (highest failure chance) are the "route" to nether goods — that is -intended: the most lawless water feeds the most dangerous market. - -## Item coverage - -| Item | Needed for | Source (this plan) | -|---|---|---| -| Nether wart | every real potion | wart shoals (1) | -| Blaze rod / powder | brewing stand, fuel, strength, magma cream | blaze pickets (2) | -| Ghast tear | regeneration | already drops — interstice ghasts are not police | -| Glowstone dust | amplifier II, lighting | ceiling clusters (3) | -| Quartz | comparators, observers, building | spire seams (4) | -| Magma cream | fire resistance | slime + blaze powder craft; magma cubes (6) | -| Soul sand / soul soil | wither, soul fire | already the interstice floor | -| Wither skeleton skull | nether star → beacon | wither watchtowers (5) | -| Netherrack / nether bricks | building, watchtower salvage | interstice crust (already); towers (5) | -| Crimson/warped wood, shroomlight | building | groves on grand shoals (1) | -| Netherite scrap / templates | netherite gear | LOOT ONLY: tower chests, wreck chests (7), encounter booty, secondhand shelves — never mineable | -| Diamonds, horse armor, saddles, obsidian | gear, riding | wreck chests (7): COMMON grade = the vanilla fortress table | -| Pigstep, armor trims, netherite upgrade template | cosmetics, netherite | wreck chests (7): RARE grade = the vanilla bastion table | -| Crying obsidian, soul speed, fire res potions | respawn anchor, soul-sand travel | wreck chests (7): captain's locker = piglin bartering table | -| Dragon's breath, shulker shells, echo shards | lingering potions, endgame | TRADE ONLY: "goods from beyond the horizon" — rare secondhand/booty items, never gatherable. No End, ever. | -| Ender pearls | ender eyes, teleport | mostly trade; SMALL amounts loot-findable in the captain's locker (ruled by Ben 2026-08-06: the wreck loot mirrors vanilla nether loot, and bartering includes pearls — locker quantities are flavor, not a faucet) | - -Already covered in the overworld and unchanged: redstone, gunpowder, spider -eye, sugar, rabbit's foot, pufferfish, golden carrot, phantom membrane -(insomnia phantoms), glass, obsidian. - -## Sources - -All seeded, deterministic, pure functions of (seed, position) in the -`galaxy`-package style — no Bukkit imports in the geometry, unit-tested -headlessly. Numbered for reference; suggested phases at the end. - -1. **Wart shoals.** Small soul-sand banks breaking the interstice surface — - the interstice's wild islets, drab and low. Nether wart grows on top; - harvest and replant like any crop. A rare **grand shoal** (config chance) - carries a crimson or warped grove: stems, wart blocks, shroomlights. - - Config: `interstice.shoal-chance`, `shoal-grid`, `shoal-radius`, - `grand-shoal-chance`. -2. **Blaze pickets.** A config fraction of the existing braziers grow a - nether-brick turret holding a **blaze spawner**. Renewable rods, fought - from a boat, contained by water on every side. - - Config: `interstice.blaze-picket-chance` (fraction of braziers). -3. **Glowstone ceiling clusters.** Seeded glowstone growths hanging from the - interstice's netherrack lid — visible for hundreds of blocks, so light - becomes navigation and destination at once. Mining them is boat-ladder - engineering, which is the right price. - - Config: `interstice.glowstone-cluster-chance` (per ceiling chunk), - `glowstone-cluster-size`. -4. **Quartz spire seams.** The basalt outcrops and rift walls carry quartz - ore (and magma blocks). The flat seabed stays barren — "nothing worth - mining" survives where it matters; the value stands up where you can see - it. - - Config: `interstice.spire-quartz-chance`. -5. **Wither watchtowers.** Rare nether-brick ruins standing out of the sea — - the interstice's only structure. Wither skeletons spawn within the walls - (spawner or structure-bounded natural spawns — decide at implementation; - spawner is simpler and police-rule-proof). A loot chest may hold netherite - scrap or a smithing template (config table). Three skulls plus floor soul - sand = a wither summoned over open water, far from every build — the best - possible arena for it. - - Config: `interstice.watchtower-grid`, `watchtower-chance`, - `watchtower-loot-table`. -6. **Magma cubes.** Natural spawns on the basalt around braziers (small - sizes; the craft in (2) already covers fire resistance, so this is - texture, not necessity). - -**Explicit non-sources:** no ancient debris anywhere ("loot has a -destination, which is a route" — mineable debris collapses the netherite -trade); no piglin bartering (cut for scope; revisit only if a use appears); -no End portals, stronghold, or elytra, ever. - -7. **The ship graveyard (added 2026-08-06, BUILT).** Vanilla shipwreck - templates seeded across the interstice seabed (`IntersticeMap.Wreck`: - grid 320, chance 0.4, variant/rotation/burial from the cell hash) — - ships that misjumped and never re-engaged, which is the entry-by-accident - lore told in scenery. Placed by the decorator via - `Bukkit.getStructureManager()` into the `LimitedRegion` (center-anchored - so even the 28-block hulls fit chunk+buffer; the RegionAccessor overload - of `Structure.place`). Every chest in a wreck is re-pointed by grade — - COMMON 70% → `minecraft:chests/nether_bridge` (the fortress list: - diamonds, horse armor, saddles, obsidian, wart), RARE 25% → - `minecraft:chests/bastion_other` (Pigstep, trims, upgrade-template - chance), TREASURE 5% → `minecraft:chests/bastion_treasure`, with any - second chest on a TREASURE wreck becoming the captain's locker → - `minecraft:gameplay/piglin_bartering` (pearls, crying obsidian, soul - speed). Re-pointing also kills vanilla's buried-treasure maps, which - would point at structures this ocean does not generate. All tables and - the template list are config (`interstice.wreck-*`). - -## Nether portals (decided 2026-08-05) - -Reality check from play: ruined portals are common ocean structures (three -found in one session), so players WILL complete and light them, and a silent -non-answer reads as a bug. - -- **Lighting a portal: refused, with lore.** The flint-and-steel moment gets - a message — *"the sea between seas does not open to fire and stone"* — so - the refusal is a world rule, not a missing feature. (Today the seal is - silent: `IntersticePortalListener` + `make-nether-portals: false`.) -- **Ruined portals stay what they are: salvage.** Obsidian, crying obsidian, - the gold loot — cargo for the hold, nothing more. -- **Rejected: portal as free/paid warp terminal to trading posts.** It - bypasses the boat, the fuel economy and the border-crossing ritual — the - three things that make travel the game. If a shore-side warp is ever - wanted, it must price exactly like the border warp (boat at island, fuel - from the hold), at which point it adds nothing the border does not. -- **Backlog (Stage 8, config-gated OFF): the portal as deliberate entry.** - If deliberate interstice entry is ever wanted, the player-built portal is - the natural door: expensive to build (obsidian is priced), one-way IN - (arrive in the interstice near the scaled position, boat alongside like a - warp arrival), exit only by the standard free re-engage. Gate: - `interstice.portal-entry`, default false. This preserves - entry-by-accident as the shipped default while giving the portal a real - answer if a server wants it. - -## Economy notes - -- Everything gatherable above already has a base price - (`economy.base-prices`) — wart, rods, tears, glowstone dust, quartz, magma - cream, soul sand, netherrack. No new price plumbing. -- Watch two prices at implementation: NETHER_WART (30) and BLAZE_ROD (200) - become *farmable* — the drift/absorption model already damps dumping, but - spot-check a wart-farm dump against a TL5+ port. -- Nether goods are natural additions to the encounter booty table and the - secondhand shelves at high-tech ports, which keeps the trade route open - for players who never fail a warp. - -## Suggested phases - -- **N1 — gathering:** wart shoals + glowstone ceiling + quartz seams + the - portal lighting message. (All generation + one listener message; no new - mobs.) Clean-slate note applies: interstice world folder must be deleted. -- **N2 — fighting:** blaze pickets + magma cubes + wither watchtowers + - loot tables. (Spawners, structure spawns, loot.) -- **N3 — polish:** grand-shoal groves, booty-table additions, secondhand - seeding, price spot-checks. - -## Testing notes (for TESTING.md when built) - -- A rigged warp failure lands you within sight of at least one resource - (shoal density should make this near-certain; if not, the consolation - prize is a lie). -- Re-engage still free and offered after an hour of gathering. -- Blazes/wither skeletons never leak beyond their pickets/towers; police - no-drop rule untouched (these are not police). -- Lighting a completed ruined portal prints the lore message and does NOT - open a portal, in both worlds. -- Clean slate: regenerate `tradewinds_world_nether` after N1. diff --git a/tradewinds-overview.md b/tradewinds-overview.md deleted file mode 100644 index 5ab5710..0000000 --- a/tradewinds-overview.md +++ /dev/null @@ -1,33 +0,0 @@ -# TradeWinds -### A BentoBox gamemode of sea trading, smuggling, and piracy - -**The pitch:** Your players are merchant sailors in an endless procedurally generated ocean dotted with trading islands. They start with nothing but a rowing boat — the boat IS the cargo hold — and build a fortune by buying goods cheap on one island and selling them dear on another. How they get rich is up to them — honest hauling, smuggling contraband past customs patrols, hunting bounties, or outright piracy. Every choice has a price, and the game keeps score. - ---- - -## The world -The ocean is generated from a single shareable seed. Trading islands appear at random positions across the ocean — sparsely scattered, and always far enough apart that you can never see one island from another. Each has its own biome, procedurally generated name, and economic character: farm islands sell food cheap, industrial islands want raw materials, and so on. Islands also carry a **tech level** — only advanced ports sell the bigger ships, so wealth alone won't upgrade you; you have to sail somewhere that can build it. Islands generate lazily as players push outward, so the world grows with your community. Villagers run the markets; the islands themselves are protected — no building or breaking. - -Each island has a **security level**, from *safe* (no PvP, no hostile mobs, heavy policing) down to *anarchic* (anything goes). The best profit margins — and the black markets — are in dangerous space. - -## Getting around -Boats are everything. Players row freely between islands, or reach an island's border and **warp**: a fuel-powered jump straight to any charted island, paid for with wood, coal, or lava carried in the boat's hold. Warping is fast but fuel and cargo compete for the same space — the game's central tradeoff. Rowing is free but slow, and the open ocean between islands is lawless. - -Warps occasionally *fail*, dropping the player into a hostile Nether sea mid-route with Ghasts inbound. Fight or re-engage the warp — the detour is free, the fright isn't. - -Cargo grows in stages: twenty boat ranks from a two-slot bamboo raft to the Pale Oak Chest Boat, then installable cargo expanders, with costs that scale steeply. A maxed-out trade ship carries a fortune and is worth defending — or attacking. - -## Law and disorder -Every player has a **reputation**. Attacking villagers, killing innocents, or trading contraband (sugar, by default) drives it down; clean living slowly restores it. Slip too far and you're *Wanted*: open to PvP everywhere, carrying a bounty any player can collect, and met at civilized islands by the police — Iron Golems on land, Guardians at sea, Phantoms in the air. A wanted player's bounty hangs right over their head for all to see (configurable) — a target sign for hunters, a badge of infamy for pirates. - -Entering an island's waters triggers a **customs scan**. Get flagged with contraband and the patrol launches — flee, fight, or destroy the evidence before they close in (a last-ditch option: nothing floats, nothing is recovered). Smuggling is a skill, not a tax. - -Players who earn enough can **buy their own island**, placed in the open ocean between trading islands, and eventually sell their own crafted goods into the economy. - -## For admins -- Runs on **BentoBox** alongside your other gamemodes; prices derive from the **BlueBook** economy addon. -- Nearly everything is a config knob: island spacing, fuel costs and warp-route pricing, scan chances, police response size, reputation thresholds, warp failure rate, cargo expansion caps. -- **Contraband and slave-trading mechanics can be disabled entirely** for family-friendly servers. -- One seed defines the whole galaxy — share it and another server gets the same world. - -*Inspired by the classic trading games TradeWars 2002 and Elite — rebuilt entirely in Minecraft terms.* diff --git a/tradewinds-salvage-plan.md b/tradewinds-salvage-plan.md deleted file mode 100644 index 1c10aa9..0000000 --- a/tradewinds-salvage-plan.md +++ /dev/null @@ -1,220 +0,0 @@ -# TradeWinds — Salvage & Free Trading Plan - -**Normative** for the salvage economy, the NBT-aware hold, and price discovery. -Where this document and `TRADEWINDS_SPEC.md` disagree on those subjects, this -one wins. It sits alongside `tradewinds-hold-plan.md`, which remains normative -for boat identity, ownership and capture. - -> **Status: all seven phases delivered 2026-08-03.** See `docs/PROGRESS.md` for -> what each landed, the deviations, and the traps hit. Two things were -> deliberately not built: plaza rumours (Phase 6 - they duplicated the logbook) -> and the chart-hologram price display (the logbook surfaces via `/tw prices`). -> -> **Phase 6's logbook and harbour reports are PARKED behind -> `economy.price-logbook-enabled` (default false)** - ruled not required for MVP -> after playtest (2026-08-03). Before re-enabling, fix: -> - The unfiltered `/tw prices` "best" column compares **absolute** prices, so -> the priciest good wins everywhere - every port read "Gems $850". Best must -> be relative to book value (or the unfiltered view dropped for per-category -> only). -> - The category argument has no tab-complete, and nothing tells the player what -> the categories are. -> - Buying a report gives one chat line: no reach shown before paying, no list -> of covered ports after. A $500 purchase should visibly land. -> - Consider folding the logbook into the chart holograms instead of a command. -> Depth-at-the-counter (the sell tooltip) is NOT parked - it works and stays. - -## The problem - -A player scavenging islets, farming, or taking prizes at sea accumulates goods -with nowhere to go. Today the hold will accept them, and `MarketService.sell()` -will sell anything the price engine can quote — there is no whitelist — but: - -1. `PriceEngine` cannot price mob loot at all (no crafting recipe, not in the - 80-entry config table), and correctly refuses ingot↔block cycles. Unpriceable - goods have no sale route, so destruction is the only exit. -2. NBT is discarded on the way in. The hold is `Map`, so an - enchanted sword becomes `DIAMOND_SWORD` and a nearly-broken pick sells for - the price of a mint one. `MarketService.basePrice(Material)` narrows to a - material before the engine — which already takes an `ItemStack` — sees it. -3. Cargo is one-way, so the hold cannot be used as a hold while mining an islet. -4. Stock drift counts **units**, so ten diamonds and ten wheat hit a port - identically. A stack of diamonds should be several ports' worth of business. - -## Shape of the answer - -Ports buy almost anything, but **shallowly**: the price decays as you sell in and -recovers over time. That rate limit is what stops an infinite farm from -outcompeting the trade game — the answer to "I have 2,000 cobblestone" becomes -"visit five ports", which is travel, which burns fuel and meets customs. - -The game is not live anywhere, so no migration or compatibility work is in -scope. Wipe worlds and databases freely. - ---- - -## Phase 1 — The trader's purse (value-weighted drift) - -Drift becomes coin-denominated: a port has spent *money* on a category, not -received *items*. This is the model players already describe out loud, and every -number tuned later depends on it, so it comes first. - -- `IslandDataManager.adjustStock(spec, category, units)` → takes the transaction - **value**. The two call sites are `MarketService.sell()` and `buy()`. -- `economy.drift-scale` (500 units) → coin-denominated and renamed; - `economy.stock-decay-per-hour` (50) → coins per hour. Retune both: a - full boatload of cheap bulk should approach the `drift-min` 0.7 floor, and - recovery should be hours, not minutes. -- `decayed()` and `driftFactor()` are already pure and unit-tested — extend the - tests rather than adding new machinery. - -**Acceptance:** selling 10 diamonds visibly moves that port's prices; selling 10 -wheat barely does. Same coin value moves them the same amount. - -## Phase 2 — Price coverage, and a salvage bucket - -- **Audit first.** A headless test enumerating every `Material` through - `PriceEngine`, reporting priceable vs not. This produces the actual gap list - instead of a guess; check the report into `docs/`. -- Add config base prices for what cannot be derived — mob drops (bone, string, - gunpowder, leather, ender pearls, blaze rods…) and raw drops whose only - "recipe" is a block cycle. -- **Salvage is anything that is not a recognised trade good in the game** — that - is, a material appearing on no island type's produce or demand list. Salvage - sales drift against a single per-island `SALVAGE` purse and never touch the - trade categories, so dumping junk cannot crater the legitimate price gradient - (a griefing vector on a small server). Existing trade goods keep their present - categories and behaviour exactly. -- New knob `economy.salvage-discount` (~0.35–0.4 of book). Salvage must pay - worse than proper cargo, or piracy and farming out-earn honest trading. - -**Acceptance:** every material either has a price or is on a short, deliberate -refusal list. Selling 2,000 cobble at a mining port does not move the price of -its iron. - -## Phase 3 — Tech gates what a port will handle - -A TL1 fishing hamlet has no use for netherite. This gives loot a *destination*, -which is a route, which is gameplay. - -Implement by value rather than a per-material tier table: -`economy.max-item-value-per-tech-level × techLevel` is the most a port will pay -for a single item; above that it declines. One knob, no table to maintain, and it -scales automatically as prices are retuned. - -**Acceptance:** an enchanted netherite sword is refused at a TL1 port and bought -at a TL7 one, with a locale message that says why. - -## Phase 4 — The NBT hold - -The significant refactor. `PriceEngine` already applies durability and already -has `getEnchantmentValue()` (written, documented, and never called) — so most of -the valuation exists and is simply unreachable. - -- `BoatHold.contents`: `Map` → `List`, **one entry - per slot**, exactly like a real inventory. `expanders`: - `List>` → `List>`. **Fuel stays - material-keyed** — fuel value is per material and genuinely fungible. -- Persistence is free: BentoBox's `ItemStackTypeAdapter` is registered in - `BentoboxTypeAdapterFactory` and round-trips full meta through Bukkit's YAML - serializer. Note its `MAX_AMOUNT` 99 clamp — one stack per slot never trips it. -- Slot accounting gets *simpler and exact*: list size is slots used, and unique - items each occupy a slot, which is what players expect. -- `HoldService`: the (Material, amount) API becomes ItemStack-based. Keep a - Material convenience overload for the buy path and fuel. -- `MarketService.basePrice` / `playerSellsAt` / `playerBuysAt` / `sell` take - ItemStacks. Wire `getEnchantmentValue()` in behind a config factor. -- Add `PotionMeta` handling to `PriceEngine`: a water bottle and a Potion of - Strength II are both `POTION` today. -- `HoldGui` gets simpler — it already renders from a `stacks` list. - -**Testing risk, and the mitigation:** under this MockBukkit setup `ItemStack` -meta does not work (the item factory is a bare mock, `getItemMeta()` returns -null), so NBT behaviour needs mocked ItemStacks with mocked `ItemMeta`, and the -YAML round-trip cannot be tested headlessly at all. Therefore **keep the -slot-packing arithmetic in a pure helper** that operates on counts and stack -limits with no Bukkit types, unit-test that, and cover serialisation in -`TESTING.md` as a manual check. - -**Acceptance:** a worn bow sells for less than a mint one; a Silk Touch pick -sells above a plain one; a Potion of Strength II is not priced as a water -bottle; a hold full of distinct enchanted gear survives a server restart. - -## Phase 5 — The hold becomes two-way - -This **narrows a load-bearing rule**: "cargo leaves the hold only by sale or -destruction" now applies to **trader-bought cargo only**. That is what preserves -cargo commitment (speculate and you must find a buyer) and keeps the hold from -being an off-market player-to-player transfer channel. Player-loaded salvage may -be withdrawn. - -- Mark bought cargo with a PDC key (`tradewinds:cargo`) on the stack itself. It - survives serialisation, travels with the goods, and stays visible if a bug ever - leaks one into a player inventory. -- **Never merge stacks across provenance**, or one bought diamond locks up the - twenty you mined. Separate list entries; the list-of-stacks model makes this - natural. -- `HoldGui` becomes interactive: click and shift-click to move goods between - player inventory and hold, refusing marked stacks outbound, and refusing - containers/vessels inbound as `HoldService.refuses()` already does. - -**Acceptance:** mined cobble goes in and comes back out; bought cargo cannot be -withdrawn and says so; capacity is never exceeded; nothing duplicates across a -restart or a boat swap. - -## Phase 6 — Price discovery - -Two distinct questions, and only one is hard. - -- **At the counter you know everything.** Show the live unit price *and the - depth*: "this port will take about 40 more before the price falls", derived - from purse headroom. This is the number the player actually needs — it tells - them when to stop selling and leave. -- **Across the map you should not know.** A **logbook**: extend - `TWPlayerData.chartedIslands` (already a `Set`) to remember prices you - have personally seen, per island, with a timestamp, and render them on the - chart with their age. Elite and TradeWars precedent. Stale data being visibly - stale is a feature; it makes a veteran trader *skilled* rather than merely - rich. -- **Market reports** purchasable at high-tech ports, radius and freshness scaling - with tech level — gives TL a role beyond what is on the shelf, and a reason to - visit an industrial hub you are not trading with. -- **Plaza rumours** as the cheap flavour version: "they're paying well for metals - two jumps north." - -**Acceptance:** no price is visible for a port never visited; remembered prices -show their age; the sell page states the depth remaining. - -## Phase 7 — Resale shelves - -Goods sold to a port can resurface for other players to buy — the charm is -"someone dumped a Fortune III pick at Baker's Reach". - -- **Notable items only**: enchanted, renamed, or above a value threshold. A slot - or two per island, with a TTL. -- The item surfaces at a **different island** than the one it was sold at — the - trader shipped it on. Unpredictable destination is what keeps port shelves from - becoming an alt-account laundering channel or a global auction house. -- `TWIslandData` grows a `List` with timestamps; bounded and swept. - -**Acceptance:** a distinctive item sold at one port is findable at another and -not at the one it was sold to; shelves do not grow without bound. - ---- - -## Out of scope, sequenced after - -**Missions as NPC requisition boards** — see `tradewinds-design-decisions.md` and -Stage 8 of `tradewinds-dev-plan.md`. Phase 6's price discovery is the *pull* -side's natural companion, so missions come after it. - -## Working order - -Phases 1–3 are small, independent of the refactor, and playtestable immediately — -they are also where the *feel* of a shallow market gets tuned, which is worth -settling before committing to the hold rework. Phases 4 and 5 land together (a -two-way hold built on material counts would only need redoing). Then 6, then 7. - -Every gameplay number goes in config from the phase that introduces it, all -player-facing text in the locale, and `docs/PROGRESS.md` and `TESTING.md` get -updated as each phase lands. From 5083367a45b9769405ae5b7f2e2ade4be0cbe10e Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 7 Aug 2026 07:57:16 -0700 Subject: [PATCH 098/112] Ship the patched MockBukkit in-repo so CI builds green CodeMC build #1: 151 test errors, all "No value for minecraft:sulfur_caves / sulfur_cube_hot" cascading into "Already mocking". The shared Jenkins agent's local repo held a STALE copy of the locally patched MockBukkit under the same version - built before the 26.2 registry fixes - and no public repository hosts the artifact, so the cache won silently. The patched jar (1.9 MB) now lives in libs/ as a file-based Maven repository declared in the pom, and the version is bumped to 4.113.4-p262-2 so no cache anywhere can shadow it. Fresh clones and CI now resolve the exact jar the suite was written against. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014as2obVi1QJj8CRTSqXBRr --- CLAUDE.md | 11 +- .../mockbukkit-v26.1.2-4.113.4-p262-2.jar | Bin 0 -> 2001778 bytes .../mockbukkit-v26.1.2-4.113.4-p262-2.pom | 117 ++++++++++++++++++ pom.xml | 12 +- 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 libs/org/mockbukkit/mockbukkit/mockbukkit-v26.1.2/4.113.4-p262-2/mockbukkit-v26.1.2-4.113.4-p262-2.jar create mode 100644 libs/org/mockbukkit/mockbukkit/mockbukkit-v26.1.2/4.113.4-p262-2/mockbukkit-v26.1.2-4.113.4-p262-2.pom diff --git a/CLAUDE.md b/CLAUDE.md index 1f4dafa..c4d9b54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,9 +102,14 @@ one-way-cargo rule below to trader-bought cargo only. ## Testing quirks (important) MockBukkit has **no 26.2 release**; tests run against the locally patched jar -`org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:4.113.4-p262`, built by -`~/git/GushBlock/scripts/build_patched_mockbukkit.py` (rerun it if the version -disappears from `~/.m2`; prereq: unzip the paper-api jar to `/tmp/paperapi`). +`org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:4.113.4-p262-2`, which **ships +inside this repo** (`libs/`, a file-based Maven repository declared in the +pom) so CI and fresh clones build green. It was built by +`~/git/GushBlock/scripts/build_patched_mockbukkit.py` (prereq: unzip the +paper-api jar to `/tmp/paperapi`). To update it: rerun the script, **bump the +`-2` suffix** (a shared CI agent once served a stale same-versioned copy from +its cache - never reuse a version), copy jar+pom into `libs/`, update +`mock-bukkit.version` in the pom. Two class-level shims are copied into test sources (Adventure 4→5 breakage): `org/mockbukkit/mockbukkit/adventure/PlainTextComponentProviderImpl` and `net/kyori/adventure/util/Buildable`. Surefire needs the long `--add-opens` diff --git a/libs/org/mockbukkit/mockbukkit/mockbukkit-v26.1.2/4.113.4-p262-2/mockbukkit-v26.1.2-4.113.4-p262-2.jar b/libs/org/mockbukkit/mockbukkit/mockbukkit-v26.1.2/4.113.4-p262-2/mockbukkit-v26.1.2-4.113.4-p262-2.jar new file mode 100644 index 0000000000000000000000000000000000000000..36252832bac5a408bf42f49a473a791b356a309a GIT binary patch literal 2001778 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

    BS(C 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

    Xxzxe6}=j$-e?p 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�dI~K`bZD*<45{G=d?vW1t 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 TVy5ZQ{&p!zL2rJrrDa2#@(=fuzCB9@lk~6tL9R z@p8O*K>~JmG!l3^ONf!H2rS~lFq*Z2zWZsJ7OSEv)DG5hg!9?d*K~_u$}KXSd)j*I1~Xe@T)9PEyB4+CHfsv zQlcEvT+SsXMn*;g8>6@%YERN*vhs?aN~2<3i&0k!8&5t=e+z?vWv!O;aw6WU$t-!g z!%{8`s>A4KZzN!{zq>-ZU*x=2G^+MyThpseRWu%TWHd^|RaIAUDi>?zhP`x1YF+J0 zea+@v;19gU^OUI|_0BFkuPJHd4PNk|Qv^Ku>=zvA*q~XMeE&T9#e6-)2~vVJEtkRG zs3eJ?1A}K-wbYz~=3F-(Aam}>-B2HGFGk0XvvVP6HtYc4b8*!rj(+zVk1#Q91(cl{ zDSD?JBSO@{Zs2&S5S?Q{C96dUXmfU*pf8RfELZG#otuu5MKi7({Bi{H+O!wwnCbxC zn8^&f&9E6%|Gbr-KbA${QLD2F;`PGgAb?)5M1jYZOtU#nI?Se$fJC#Kto$L@&XlI= z(xOAOuJf`daaiLX{-U`Nw>79hLHtlh62mB!G2)3nO$;%^4r-xqqpOY~-%hSEqehVI z=B_mGV}W~`vGZvT#^ljx8MfY{x%F}u9zu%tYbP8jEa!wqVb)knM2t>)ge50MfzVkw zDeUGaLwv)VsZEuzsgWP0@#YJqFd0;}#R^5BDrg!kCtCl@M3=`37v5mU zlBByfc3Gp{Qaq&PSzV@1w>BbcK8z;*uwxL87!SSj#d(W|B6;Xoy?RFg9p`UF?xMYL zj|71(&i;q2YaG{Bo896CHH7l6cQkzY!TAw}z2c&HG*D;|x~;?t_qv}@eGi)}JKP*v zrGv@24k7tIv@=xp!eNxa1=eoy3Jd4}i&d44yE*g;oQ{Wgrb18v~;8VvsX z!ZmT{&rSKnGbh?eh{HnW&E|*p?Heb?bd!n)~j+@;(;$Hd2Xpq2Z=#Dv!ZjnR-X z^@)59Pw^N+)4n%@b%&YK!|L#4RM~lgWr;0!+n40xnmd+QN%j;q3cZ1mo~UzYoyF|1 zd8G*FUI|=DNp4XC))?+`>L#_UKIk&G%%0N2V3v^-!&2PYac1{k)?MQ_v9Z`-HY<|_ zPr<3=*hHeBXpf+A>QUtQX6LY-h%jLS#&inCpk*>AtIn!#45-aJW~zE5MZyCqQa{%$ zdfHgbMDtcT)|_bHhYb3C24NDCw0`NC)j)%H$d_YOQCd84+-i9QJ7h5IeOXZ%lk9Nv zkV^UzV@hfa6pQ>eo5FgEYI$tbjf7<-YdVdKHwAnyQ?FH}ShRQO9WCCgA67}O%dI`H zQ41~`MIATwW>k8c!*>NRyFT%dd9PTY0MH4p$&)7yk^3ct;kyf;`%+zfa3X~?2EOXU z?w&mX0QRR(DO_hbQgTWu8tWsiL_Xr%<*;~@N7GYC-R!_t4v9#91vJ0XJ=7-?DE@3; zW14V1C~oKnW{3x8{L5iqqV;8kNPUQF>sK+L{UYU`j{L^-2S;<6XA&d3c9Y7LDD00l z*p=s?Hjj&)mLxJ}I^P)P6j{-d>2zi)FVtt3>kR7Qnb2bfa*@SRJ!DbBmv9xc(+UpklED!INm zrdNEr*tehY_M#%g+^y1KJt(|l>%_lObKk)i@uFD1N0ROAjQqGf>>29*y`fT(mY0cN zwJ>EQMW5C;wV|WhOmaoQa;v_wvi}Eq;<6R9&oOu^&k`^4GP_>=tV{P|WzIRFn=bHi z*OyN!p%Oi|`H*{NW}Pv{)Lp2e8+K7Onr2}GoL>ED61CL|uOf+EmEhS&FA#(FKwO_@ z6^6$u`HQ8F+h)da74iGz@xd(CUsnX*D@I0|hKSA&y(8iqJ;^ z#ifnUDhsEyclcNOipX(Q?c=Le|i;ItmAdduHAijKNwR-aQiPb;ri&?ZG8lKvQFOjaap`zU1 zRw4S@b%VZns!Ut^Ns&|dPiY3M_BpP!XH8CCm){UKQu$Rb#fK)(z!{F$gEZe8p5HXP zelO-Vy8peZ&ziO`=GJg*=c4?T@++ACEeyZFWY1m>ZH)g8|I@dW*MHo#JgXRrP(V*@ zc~F?+KgMc`xjNYy{@YUEf3wZFCb)uNWc#ZFq>YSmx$6=Vqao6Sd5omdRJQ*&++6hk zA8v+Xh>&Jr_KkaRdw7}h^PY--e?NNU|17g73P~q{C6NwltpHh1QmY~wmm`{*SSczm zF?3J0$q1aRm0Z6m*vDuAnz=zt<3}vH{TnO)M#GrUe)m>xw%Sq+r3m6l2kY@tPLzI$ z0{r4Y1k@~!eupN`xM8Cocv)#b)3f|X9J(YiEgm%>b&uJuV!{dVj9VQEgVWM49ktJ` zk|`tOb_^SED8EA9RQP259h^(tEw2SKei_D=TeR$M0s)PQHqqpmGHLA)8`vIsu+1Wg z&lYuBzWYYlz@MT2j{kHAlk+f`GCW;w) zuRNBtkE)f&=o8mc>*t)=NB%$&brAa=asOpCJJY#oOzOSj5^2PS;X8t&V>46)GaFgh#_uBQGpPq89Z_c-0@4i z7z}VJjrrLct_`~1ZhVxbnmAq>>$l&#AvvCiAmfg|?=T36 z5Lv#MlKhKf40mC)J`fy%*3imYg0BjzOiln2vzX{k@@OtytI2;0JQn6xVLe0!{{E8v zJtdpVs^cmze0XHu^;h+sMAhti@6I;d1aqc832&3F1e2Y;frQ*B3aj`{48@^!a{d*j z$81sBMJI@3JW>`n$B`~m{>i-6lcNSpDY=vdBz4uO!=B=;@XS+2P70OVrnI_o7g9we zNDhScIx2^XV!Kg>jiO)@s6k-Jw=hG32N1_tD$m;WM`$4$F9fV%t=;o zQ`!3Y@P{|Qq#?NFO&vT4<7gDh1D#B~%>1WfghqZhaq2}^6E_{y682Z?prx$s$RC~r zDL;5<+>S0p)NHN9o)Y}Fa}DYgzAvX!;FQTC4dLU=a99kWQ-s1}VR`hEJ$b+h?^p&Y zvYp^jvWS@9s_4qK%)h9%@9SnzB{R9ag=jTVm+7Ut$yjO&F8OFra7eXgYiJR%q zPNTjjjsFlp@`$d#tWA#CR}zfmlRCj*n3(-0&)sxa@B=)1=9plzMPZGN;|}Fq{j_PD zs*Bm{fkQL9SA5}YqrRUR?bC*K?{ILV#2bsoch;PlTPj}2jYtAgRWhpq;{ng-k(*cl zE1r_gcOE$DC)!=?*`q>bgm=IMc}%z1<#%oqTd$LRa;BCE;{53XRfv*OB?I2D-k#o6 zc`5w9s^Q;A*Na1U358kTjG~5TxBO8j^2FYw__0Lcq%2b7GK*XQMZ}iy5Z;D zxAGs8+m*GsOPWCy4RWh$UCH7h&C91X7Rr9|yH-eGM(Z_Bfg`*a2w*(NW~5J-;uyFY zVZuUuLYjcsIOERl_;G|atpUQ#4FDB}q=IhdX)+ud zAqfF|YP0ydC2Dm=9|Fs_eW^dgzr87DNuRXne#}A#=|$$!$wD+D*cq`QtSNq7^CHxE z{0OWPQJi6^EKyDoyg>a6VBF&i{_Iy;NHV}R_s?9&*i`NpZP4krqtktB{zKOb zrWfgbFBDM~rd)L)SIrHVWSd->;mk1JgQa>GA79<)jo1`cnh>+UDT1VGE*iB7U}5* zTbbjj0L#^*yQf-=d1kN-ZY~n@#s)3QGb}R;A@}~zHe!iEN2R}ls=zWLx?vUuZN_Ke zBHOjOoyv>N$b|N0nY|{U-Q`~D;Fqad^#=n+xxt&snmB!DnvK$#O*tCyOz|e2n8*yWL60~7?9eP2w=xUi4gu{6IRHv? z7Q;PKsN@=qXj2pcSNu}jT2v(8!ZuS$XpTnUPL$&Gy6bnfDD1@l}xq0k^XblqbN zs2VDT0?m0wrQ)f$I0HT{))?Owzrb+KBrQ%+c3~4ZV8}JDykxsf2RYPwWN`M`ai*OR zIx)2vqZm9L#=P=GuQwas)JeT`T=KxaT*s?isRy4N%mOYmZE#e^O_gauOXU(Lm3xdb zwO)Ya+ysZEt~5*|nk_?{re=iC%=}`j_@Jc>tO~c+;fI#$ifFvg`nFo0v$i*pUg7p2 zwO&xF6Ru_mYT7gwIlFxz_jomXZ0mFTYZ!4cVYSd#_qG&trytFhOpE6(Lsd@h#$9!p z1t}1^?+w&&nLCg;J=4B!E-JnD}%v42dzzc+(Zq8?SYXlj1 zkR=rx`x`b0-}Ppjg$IqC^1E+br4tWR^9(u-isVeUJeaD1C;Sx#y=I^yEHi#&m@P=> z{U_`;Yki3)-E?cTdS&EoZG`w0SFzGWBb``#tZS{H0EGQ^VE1mNOA$V~pkBz!WG?1bMU825c(o^U(B*y*WqE}*g4#VcvBAkV!ttGSEB6_WC z%+)TO$0}3ev%sNM>{#~cdG}!KYDmRJ%(M~enutLYF!>1TbW9}h8tMIoCoO(MQrgMU z;&5bYTSY{bejbq*x*`Nhtw(YaX0j~1)$m{f^BjR@QPf3z`nuk>{?W~*VpGSQ|J)+P|V}b+GQjZKzF4@Oisw$BNe$5@uVM4hQpWYdXDT z>`k=uzM)||ySH?}oRLKZS8;v+ctdq#!=Dd@*QA-Gnft&PO+P&kz#~x7vQlw3`|VMT zaBH&>bK5FqVRja^_WsZmL5RS%WT0q=AWcmG93-T7+CB5*39+Ya&EL(acekQFnZRoO znc^7q)Z8n(Y;j-xU?k|{p6oqAO6RAq^b{EJeLGT+htAnRm>$lErq7SI&o6(Mw-s%m zXh`dIJyw4^6~>Ruo~?&IqRKF<>B!}BD52wgW`}1EJ6JlzhPN-1Kh{!heSz5)c`bXq zxSytmuY#)m>%P>rH_gk;53B_l=Y9kJ0kXHH*sYyMnD^Y>*r&waDfBsH_8%5%8Ex&k z0`*saZDKvGKYlW6YPKXu|C;>PLd*O6uw)A5on|^N`hju6-cq5DAAEIrp9JE=NMBPk z3Ep1r=ko3+Zpgsp+vbg4b9gJyOAnGQ1jo%`b6fRCuz@|iKUd`MFB>anQg*R}vVNG9a4w`#I46UU)wCr#NR#Ri7meWyLsV>lEAz?`mzsNKz`f%~4U@*1-zg0PibiOB>>mBQG|3Au2|P=JZts^O?6`(`eS?y| z29hgipE+U9Q`2l}MXjIGupYH6)1jjdg@o1%2MT3RXkSQgf8aNyV)Ob)S>MYOJG5JW zuA$d`w)kA+g=n=%_Vp{&nO`lt>5_mTEr^6z&v(-mGZpQGVSrs z}X#cb3YOI9mn_un}gnmD|8ayWL_cs zjh$ZcKL&an-~QQs{S?q1g9myZmVm(K|8dq|+{x0;#M#CE-)=1+?F~r0;73ks12$fP zBvnQxp@mvnNe5=@NtFaf{CyBoQK2Gnk_9Bb*}Eu?IC>JkLwr(w6X5lVDam==j=k|P zZxDc@LYlf3W@=z|&pz5Tzv$}tczqex|AcW>4^6~E2~`o68^4qNd%r2D>e5{Ln>{#La91Y-@(;-jF+yL7nv3gH9p<*=HU!X5$rdfX5L*UnD1-DqYhUXh& zhV0?Pjdb9uIexuF4x~BwxT3yUj$nj@zococ*d-9dEV27Q;489 z!6H~Z(L;~RsWPS>V(god+|53>cWe+5N%MjzH7iYvM5JVYJxBmy%*8zFtteDwMib!2 zf1yo)4|=CalOJauIr^rW%f{2ni&ke2HNdfZ434eHT=W{R7$0I6(_^o%+}t%Ze}M&= zlA?+8Ga5%3(xmjU75kWgRjbI}g(id8a~7LZvopx1(Y`QDhuH%6 zB~!vu_VM?QErm(kIn89k&}}_Kk$oW|<9wjflmrC2lM0Lp!8k~<<4p!you0mx-Ikgi zyWj?@FM$I!=rrl6$-?*5NTQb&4ulA)QpV(WqVz^{uE$fBPh!EOI@w_yY$Vlm%Wx(m z@+^0_b^si2e#|J&TNPi91YHJ%NDgENiky;Y zUprEOKj8PL*RME5-m6zz3jO>f>ocCe-F3e4iR_9u&VdN{7QCWoa}Zj13A1cZ2rdca zxoZ|h_W5gA15gR*iq^x5*1!6WMBYL!r8v08cN;J*ugJ1dy_z6NLZ7x-FVwB$s`L8P zyC;{Atoq`si0*{-k58;ns^s!q_rb1?0%v$Bg!rjppRXEot5~G;wN9idV!~kk#JGNRfd%s6Id{lj6ZBsEkMW8UhIZy4orJ8tv-3X|p{m;QsDfzk#VtjyZD@J9=tzP#k@Zf9$DC)|Xk=?ZOkh25tzIgehtnVrp^U#1^( zKC#te2f*4v6JYB-VHHpKh>fP7G^kvk%QTcvCdoBBP0&?W!Z>GD;^ScZM5gCyj>Emh z2amX;xV07?vQ`flNcPQ($@e=G^QLIkFw*= z5IdGZXcU6TuLAx0!wS)po4#W(2AoDxiic zyZWFuCT9~@cwd7V0jjaN5c9%@5nItAqNfyvynDZlGwGd`>#Re zjwtrwg{9dNS=Iwczn*@oIX8ntTdTN`naMVy+71gcrZV5Ac=@19S$;KR%u!=!b*z4K zjjUAIk4~-s`Xo)mZWJ1860aM|d7*lZ`3iOQQZebe>zcECKOz z8Za`KH=)wtcX@RmKJTXK!-Vm81WcebEeGXU;cB&hZg9_zAT**iE8@&YpFy(OAE|~Y z4h*7&S?}!ye%S(p+Yf;&-*c3L9Ba~OHOU)p1?j1BN~dBU7hbnFT^jJNa{(GQ^zG>6 zt3nL5@}PS*@R!q%-tJ;I3uw2&3sS&h2J}da9f|EL#J~RDqPFnS#X;8`HP96Nzx}-_ zTbloy-tYgitiD7Tl<$(MR6uJCOVe1*pb!aIfng|B-gzb90eem4xIme+@tmuwOI_cS zANQX%e{1Rb{%vr1Op78N26S4)+c zB$*o0>1%5*C)3aZcpP)_FvoN{DW;CB{8ggKam)^by(t-r{PBh5+N_s(K zqG5)Wc8&ZL!N@|0F7RDk&kkWbWA6eN;jRD!rc`OUsp4X+>fedrS@OZNu_QQMgBH9ODAw<>sL2T5m_s~ z82GSjW-ro@?3znD#|rWhf7R2B7m zAl%>D*IT~l#Xm|e?F;#%!<3qxGmo@}p|t8ID#>A}(;F6}s6(nf?}ocm2y6ux2Abl7 zU>rNo*&}oksen#0oH;xqOP;CuPQqe*O*u^Lse!rc!?|i4bZr+^)g_j@j_?Pj_S z3q6tF>89|Zj`+qncE^9sw2xrwZITLUHT!cdHCM&5)j)3LF~x6k;>|lmy0OZA+u%2`i)z`dfssTKB}PETzET;8LO6~i73a#bM2gk*f8zf zJx@s_b)W3()PS3Du}F7-cexoeHU7z=)IAMp-k7D_a3^(1_SaDzD|L95i~n@nBLT_2|E-n+$p3@7#7B! z$8c_P(&OM%?Ms@CqIIwiSDr3v>cLikVOZzl;BL1x?MY)$Oyu}D#>+I z=SB0RSA~7c>R=Q|NCJa=tV8J%;Z-++Vu21^&1Y5QNXh72lJ{H-V>Y-%2OWVgDNN*A zc|HZl+%m+=9TF_dpw`FPlC|2m(mSns6{Y=`k-p~WY>LT>Gw-UN-}b5rMY=G}+6jvW zm6O(?b2(mGNa)`oaJotEao7@1_2Q~Q)p}8`YD&AbxjIoz=hC)x>KS8OtBZvGK8B43 z1O}em@90@#&jNxJ1aqedzujkk@iINboU^C98l?FFH!nVEBJ4V+i=L`If$4*7p40fq zN^H&WfMetZ&L@_LlO%VE3N(ee`8BHXmyF!pA^5?ttZg6)BMaYEyslsEfa&87R&-hE}J>;}XKu7@mk5*6b9c?2c!-TWRmOq}UIG z#*JFx_xkwy^cCtlbNH+ua(6HI?L891;0L9y9T>q?;0vXsbuCo$`Pcj)^>FAQ_&GFIb(`w;{tzS*w-L|qj>9#Cx<&S6#fExmoK9^dYAHLZ?ZC5YUgrxyT<&RalBTx9>a^iQlw zVcy<=@}TeLx&0t#A3DX65sGPuZTw#Uw&7(%;R>F7wCi7tp5dXx;n6 z_x5P((?!rQl8}b5i1@c>?CN&C#adnGj=5i(&980^N+fH)w)xoYq;C;4$#Afr?KNT! zzB6RXVjesj2!_A(H+aycYNpbDNgy6`s5)4{g*Ftq{_Fxz;gUH(2u$7-AP75x^OgS7 zG)M+WBOu8gY{LQ|Ei+UagO^~N#Rk_7_&O)5TN&|d?v!6)Up%IRV+$3x4!XA~?#V4` zl-WSfPg`;T85%#@wx9B;JF!rn6o$nmnBX*GdZw)51=mkx1W!3ijAi8Dm1xP{C+cyV zBV0aF0T@k-&&4+6Rl(n0b3z=H=BX}h3h5V(TLU6-$X#^$NrAy9t740;6|9DFmK`yQ zJFbS6Ui~gv@Z&8yB~uRl>u85=n-y#}J8|#KClt0uW8LPQi#mdAQec+&*e2MGy4d$) z@^IlUdAi<&O3C}E_&I;MZ^j$x@hPf88>lmEanxCK78!p`V;y=6(`hvyI8*r={r-bM z#-}yL?}fv%HAx_R{z%KuHJZQUA$3%mU;jkJrN-64j^UF21GvIZfD zXyQzxS9UepY|@o-^Xz28@iXf__Ebw2=SC+sapVOg{14G_$ECJCB;evNfcz@_zxrSOa{>|mh~KEGgAJ}*UL;mNB! zf1w>CFYrmm<>szFij3w}*HUxC&8E80R8rqnAp0(j2HCaE(XU;3?WK$Ks%#a#ckx}S zsgilf5;wTlA(DQFGTP;lIBM;#Szt?;<-xo}FYrxJ4Jz_3s;tKG25$(z9)<3Ed;jI< zvG!OpA?ijXR2Cn}kFR&jrTM31#EKp_AuOWyuj$cZhmH1gG7S=v=pS7wjTr&7hU?A zGlqJ%{JHUSvD&?2y`4)xLSx?pKD65#D5wgMa;E(yUY>|e(326s(5*UsdDV4lDR%$# znVmc*Gsp!_lTrIYRj$yA%?u97?E1{Jbnw?RskVz@Di{rwe^7qluFBs#*3HEWGYk0$6};?Zo9fq6*CGD2{N1u3Jcxv{c5>rg4Sz&pQ4(q?uPSdUC{cigVWVF`R z$UyZg)55UB63xfnAf?|+k2zy1*LC|lioxcNO4^gRM{dX1$g63_k>lO*#n^G(Oe5W$ z>cVYSgJd>rr)>(OE&qx*FCJr>u0*SGBjMV_b7Z@lp>@xLABt=Lk65eSSFypz($@Z} zK=2_4bs#VPUhA|&P1EcCc|E=pc2wW6Hac|zf|MBBv_sFJ2>U0|Cgwb26lFMGl_&3) za-7?$6s3ZYV&3?oB(Ys93vM&L;Dq5yJbq`mf>4&W9>;<^_dR8N#Q1_wnlVQ(oa2C) z;9|pg!|iD5a24Sj=@aIG;!fff&iDr1It@C^;E$h}cnNp=JdxZqnUJ(yG=?&25Wr{> zN29RR#R0QpTdoo8=WnVTGxV_K`rNferbgUE-f|~XG!OZ{!`3$-Rsvy{O|eki;#`YjMFFJnc^j!%@KxyPMn>YjYLXdJ(O%J7p*uli zGurL(c{TB)d|=@Qh~@mgNC}0rZN)0F2f}u?^j8s5GMz&bNJj2ji|2BVD4`;o^ldzq z&xKZO7hAj|ryN<`p1+9KKSYaESR*I>WaqfD?B-080O!uqD)|hSSCj?bT*~#Fl*@mt zigsreXfDd<>N?f$=@^a2Ycp5%gVagEed`W&)t{6R5Wxh0vBZhTa*>t_m;@RHc(we+ z9GagOm499q* zu0`YJD>;L0XDRPLCL^biW9HT>$nu+G*)%$$szPW6)<*EPOLG^*=k#9hU`p8S?OPW% zJ033UdS}gE6pf_l&8swHbq}b+n5Jt;6SRw*c!%7sG2n2TQznqOX5Q;gadi)|$DMZ% zx{rO}MyO*FA&7O-e=G@{TH%+`LkuleyC?$)5!`UbPQYj(-V5@r&E5eRFA#MP*?Hk*xK-WL;|K_ljF*E^DhyLxbRbCTAdtbr|jwTF7q9=kTCkls$ zw-S=Dkb#1X5;mqE1pg;+2x-o(IPvJcRBn01=kCk(B9K&d@B1f5v>#V_ zvVr=d++0`$+HQI@8Y@7VlPd*xt5obCOIk79Afey5saDl&VxF-ayQ_J&A!TOLb9O(D zdvKLA3C>FVUo?&0t4i9xJR{&UAeu%?1c8pB#{$lX^>qxKZa7_4H3e3&HcMb*!CYts z^|M2C9^H35fJjBQ#7~Kv08qbc+e~k51TU--Rn;MDM@)8qti5^_W;%iplCjW00|Xd~ zyT&Azz0r~TwMgokFp5$+;=p>2t?1+;EDcSu8lv4=WuPJoWem6BT2h$NBolf@exiq% z)6Ac6F`x^0LH{BA7(vv9#Y9IjAUwQ2-*Jg1q8eVB^l1#Yv^$Y4e*#COvK2j< ztQao@IY~d^@Tp!&3>wQYcUr)+mSMVtNHCJEAd-A-2dncn2=TT2+r&^K@iK=b!9Lmk=E=)cc zLMXYGyxc6_T3IebHKR90Wp(V!7+x5V9cBR@tBtzc1!=Ix-sjV&jjsNb^quHp>pZg} zfTA!S_sqRM&1<&fJR=j{jbh)mamv61zg`Gz(w^#u6VByHFa3dS=gnx(2Mz6Ah}?m| zq);go4VV^+IG=emm`3gLWHTo$I+DFevKb3n;@~-&n#-d2ZP(RGV{D`Gr*nkyE-u;? z*RR~VzW8i-RW`-U!)q3-@~h zB`%)wJ4{D3X+cSCbSeT}cj>h0`6eR)Y2l=znChJD2WDhYt$(gx*EwN+>FDkN)_oNs zoaA%XNj}BfU@rXG9h>|MpO>4AOm@JV6wNobV*(fVuaZts8I3rU(mSv=pD(CGY=yjp z&p!S7#OrpvS|pv<*!n$zVm34LamaJ-O&oi=dn0fD6Qcvy`unq0SpZ}2x zqZ#Ub*Gr}}ZbUGvzF8KWsFG#ycGU}FWD-2#dP&^^C^n4^Zrs=J@-CEKSEv%s1QKbtZzm;o3t$XcffcBM6$lw1 zGJ)R}T^sSs%8z0^q5iO^kVCR};Z-zN+U{(87=q%u6tt#CpOLflU4&ZG{j%mq^y}^8 ztJ-6EuIaBY?XQi_9%2wr3J#a#wCwyWeU9L`f&Tw^61%1+;c9{|pv0i%?0`J>MfcPej%|NGmm{U=KwOhH7i zA5fIXKmx#Rv8v>8al2M|O|?~wzef2ShvFfE{CW})8|UD7y!HsBX`EsO2-j6EJfbCR-8c$**Tr6G-1_sZY@X~^0=0iWuR}G?#9q# z3KBerH_8cm!!r8mL)KWiZQXANVOUC>iE%|-vlMPY^pL(lUQgo_O;NsHN_r?oBjlPO z7{zdIAI=OQq3(|`z$$=2*hLbSV8)9Tw6`HfCI5(_!~){Ny>aR1%-V$FQdiYSO-v|4 z44Y^r>5y;1Rj6=PZ&v{l%xtJegRoN3PMSq40pgx5ef?CuQN_ufS&vFtb#|YC@%9X6+yVjnS+Hvj}C#v zx>h4vl3EGlsc14gOu`PbV@Dv*{ZPs)1`lPbk5V7H4wJa$x*!eOO8tQ`o8NT6l`c-Rk18BzF*Y;g8lL1B@2zAys906`2MlbZDH@;&@6& zZPG)!ymT?m#Cuz2xYBVUU0KcKc6zv*9Rr)u;nHo5bVW%satz@o1MV|*oTbqC!v?lJ5>$-~Vm;H72IniI_cr)kTOk%1H zO30=F!TJUAWB>DG(%9{rW_==z(??<_KLcU3At|AtZf;AT94= z(<BP^tb~UBUC_zrCjBtjs>TR~L{eDv74lONYrm{=8 zG4qP&pMkz!h@-f%?@oYyGO;begn4-cuR4(i)8~g7b`1U|XpGnoz&0dgwz0>TukXEv zR37sbVeeAj-dFLFn7NM(hVVvc-enC@+r|k|Y*$y%d=Tri!aibboY6pof;+0&f;rSe zY8Q&nD_-r4Ty@T3p*~Rv_P|n^f>dTBq*ej@QH!x?GSL{Bp04ouX-Bs&NB(xJ8++9R zuC`RsS=n>|@lp>;=4|~45o~WjWQIr2An$H<^nC1&0#hm z+5BYabJE?@zij7syb5H0!r1v|m}ciTc*PuaUbBJD>;JfGAp=t8nL7R3hG|7@O$}`W zGs^U@u7!yW1GFqWsUkBXX|N}hnMGy-0O6#`=YMol&?i^lPW}gJkXh+gRn2ps_ib^} z*%TXhkmzUAv%wj{(M7{F=ga?MlIMVA7l%|w4I`>1%*)581}^$~O2TG+s!Y!b zrs?Foko(0ZOI*epz7`}W`Gy3Hj<`J>L2cn^pQ+mZg3b$SW0Y~mLDWqmW#CTpq-p5s z8MMxkLUZvGH#n;dGUAzAOv1(*Hm_)qP8fJ4@*kv+R&_RKZWNwJQ*w;XsA?B4qy=e1 zXZD6t%N4{5PO2@bwP~X4^Ru>+x4fuk_Gv;Ml2IFX@UCex({4CY_JOQY##1-3r6qt1|8FfrJva940|0WukiOx}{nfZg| zY$t$vch%d}zyLeU#!lNK*}U0W18uF84k2yFU^{J2AhXT5>G2GEa5wA4zBGiPryn`a ztgHdnvg&%ArY-xrw+w?*ljCv{ej z&JCRMIFvk$m7u5=jFhIPAIqD8mz0xs0-Z_P&Jv$xFqiVoHlOPCr=%*jjYc|{Q!}e8 z0|yvBV~*RD5nt`t{3?Q6at_O*k@>tv!|);{BAU!l9_u+N_tmAgwz0C0IUbEDi8=-y zek>X*=aAe2W<1MZ$JoyVNwm|HP;-qtICHGm_^Ix3UsF#i{hgFOG(&MyDy~ zU1Xun38NbN@%51Q%`r7He%nHYMzksV($%s8I3%O&dO$H8xv!!Y>+~b@qqQZv2)lOh z<=>ky=eDU?%kF#c7B=~~nFy9!FgzJ4Wh#X<;(*mEga_qjqdeJ+HyDpn2x(S3Z`|H( zILRgO?jfroBu>ff$5SFyYU0a}6$=}ea@1x8=NHYS71=63&LmBWbI$UQDYW{S%RMK} zlr{;Kgo{;ZR~KNMx&NQk%>)*cGcRahm4tPtl+i-FBa;qd+6DT{r(Wm2g;Yl`Fg%|C zS^{>6eA!)R68TbRB2?2S=e%Hf*FpjMf$&W!d9Pyg7(DqOa!g<`ah~5%I3lJkm$Vs& zjVnLDAH(`c#_7*;AS_A8)|@;B`@vAH=7^Yt!r;X~A{5LSichNd>&qsX`e)@F$K+6U z=gn^VVR1Yk>p!=he3fB@dy4^@-gWndGs(Tx?|nbL%)a$4Lm%(OOx*JP3|d5`@Ti14 z-LNK@!Uc2YP`tppW-j7SQD2V=)z8Pn?)G3@Sy2omOjO&GUF#oZ*EfVJX&B(&!~(m&hy@gOpg+Go zVzE==c{k$u+Rs09?4MPL>ll}~AG%j<`bh5_5X8oRdi`i~Kp4W=&)r_r@ILIfqYQYt zMC+9i{)~nTv;EEu9!Cy{yhQ443H)??fU|H6!&}tyloQ4a3g!$t9H((fT|Xv&TyY3; ze{MYN09yr&X+Q&;m;0K3!!Et~8*~Xk$O2OcwV{FYyl68)NADkOdQbOxm;65BkHqi) zsLE}w7g39XMi4sx4cJr0)Xmh!#`NF7p1+krp!p9vNsfeU10*GFw9j?2wTIik&1(;a38F=TDuY)T+2@Hn*G{9oAshN<~M{?&sz;2a<>2oy)6Yt)N#0iq=6+r4G8Tn$h^cnY5Xyd zb)`D$AIRU)mvE;flrKiAry19Ih^)wl-8 z=1(B$I+u$uE3w$rU1+24*xg1*(iDh_ADN}i4~pG7FrNO#QW=W$O&5{13UMa`dMwi< z?erUOJ-|t$oqs8?ldu~Toodb!vkH4Ra&S`jKu^VW)7`6*wcWU0@>SlQa087PQniCT zVgd&7?8RG6(rAe+8N%<2v5I175))Q_)`Q(&r3~M9p===&X8f%HF}d&VTLtZLF2XM#IKd zW81cE+gh=0G`5W+hV?TeyJ@4zfzH@%2T27Ul-Q;X&T%qzo!-tI_75J{!2v6h6NKnVI9-u&1}tX>+OBMY7P=baD>?NfCMBfK)Sd#7oV zP`XUpJNnIg`WV(evOX$`QD$Zj$u73-mF1-II%>MnVtP06B3o9u9lkgh zrgykx#p9jb$9kd5qOti33$;i zR{x+}aH*mA4+IdjK!7|)!FbQ70YOPhyIJoO)W|l&M)hqj=W|ps2qVGjYqO5o-TTi1!lQt`Je2-Dc*$s4`R;PVgws+ z*EC@J^f&&=nJO5%JjJwI@9jzsRoHbK30HRa@M(@Fbaq5HfhGBEZS)SjsJj41Q4!aY zTHBGqA>w8?7I7h?x{H6GNs)&Wh+%q9Ffy%?53~3%P9i0rik*YkvB?Xkk%aF}za@BL z0!&V-M1~*iW-1Yv^V8zL`BXzPzlr@;;$2Y_WotguI8!RigAv@pI;mF}m6Nn6B^qMh zQN0$BfbMczTwjb$rnnpvi*TbMVmZ zU50_NfDOwKEHl^nES6s|OfUq`&{>xz%(#ZQm6?@FM9pY%2qiXoVx)a~%HH{l)&T4G z<;%CD?Gpt*TYvtpk=mfkc^&=JI<0h44z@^jKIMjZWC%+2{Oa^82qwWJNUxF6040KG zxQ1^hwABsc+ZN|%+hvz!T}0KN3~ASdlQ$$b8swt)gw6JOi~{^9QLC(DIr2YR>v-T? z*XrPzoW`7&ZP86_;ro! z%FTFugh!@Kdn?#jhP&*tH983+U)#Fxlz3=#7pIG>)Nz*OC)ermVlfrImNutU> z)_`1G7Rtst>^c_{wQDj3x{iDg%~C&!$G%7`%rE>3^EgCU_=@wy?E{*yFqbII@K}|9 zt}uiD`dZ?lLI@wrE}@09mZsSI9xi_CMeu!J+8{LPv!)CQF2DRFITy5`RJzl$WmWS0(?0wM3yW7Mr|vJ-$vOHNuOS(R&wBFvU@Jp{InP zK5wx8i)_)H)E|nuczz$9;$NKjyj10kBmv~r$glSVZkBN4uJE(t_>8`LFdsjxzTkgH zU1&FhBLP}J$M-Rvo|m(^e)97h$hmnY@Rk^Ny+F^u8u#H%2m+$LnCE+QDHeL(dP2`S zA0Pu~o$oRb4a>XnJvvjH%10>^MZoRn$`^#A_hX0L0?%mveDQD4{a2RH8*mOc^s_w@ zncflxq4{Op)gBq884^b5?ablbOCPJV} zVYhXcY@`2D6sSNkVrLO}{TgdoMxZl689o%T%?)lVy?ULQ%KCNpG|KcLMl1RoTt6@< zoIq$=gD(1W z`@(pQnRPOzF)&JIZW-KD8cQ(8v9&ZGiw)eX4(`E<0K8A#!h%k)e$j9c31iyhJDgfc z!hyU*;qhF3M~}E=)!HZe8T5dwn%j^xq*?1na9dzjm3_^Thxm@XZO1ffZPzj3DEd}B z36PiPl5D2%n6{6;eIn!;V_d$|PjS@Wjm-*fq5m<7G<~hc{JKzWkDrIQI3rnB<#*0` zq+qo^r#NPUnd&`T2>~>Jb1AWch$K1<&}hfSuF91*sfV!;I11S2@b@!d7d6^hFD{}q z-7sm@8;jbPFfU>_IP6S2T6$rB%7yS8rDu^d(8Lr~9q3j75FPKZqQCXobz2D2Z^BuO zryfSUnyj=DWptOBVg%NMQST0QWAfc**Js6zXC2k%vitYT2R#Sk2B<@>i1a5Nyv0&q zwI?!{a7k@Y%%LmNnwA*Ta~tG=Ui`Z>xi=ECXZJBe%U)3#Vk&CB`?)zFRPEoXIIl7x zKyTVEd^RTE$Trj7p8hLL{YH0m8S8U}&P*KHGQQ=ttWf-9>XV}Q`(We5owd)?>?^=7}A#r{OzO3*YR!)iCmVCI|PDy_gvAUMZfFl)sEE$`zN%jb!! zqYh666C|sTqg2gSVfZl2^k+ov<>2LRKGCe!d>aOZ{PcO9Ovl3N?7+6AsJu_~_Uo#SIU;>XObT$#T12a_$ zCYjxtL4HVx);Isym*#eRas9yOu0im*OYFa$NaVpG1{X&Ub0rr?Cu2+Z|KZ&Gzg8LG zP*P5`hE7~Gdm)FxhD}T{gh?cYPha6=6c7oc%%6J#Nvb;+I9v9I))0M9jd6^FCzs`a za+Ag~KMJci7>YB!Kr^<$u~L0PppH_z-O;>dShZS7hrFEW76MF_Uq)nKY0_&R=cv_X5cD5OXi@bIGL+>r=2eEBzj8|l zUP94KpZuz@z%DM&gICy34MD>yXQ5?IZkKW1Zn`gyP0?G;>d4985hmXM}o0JzEqG(^P<_=^6QC!bGYUWlo1{f*G|_@AQY9 zLcW>$>W)}C$WXPumoCGDQV-R+3sNC-KZNgUECXCl-|b8U(()B=;#8dMGhWK*tEoBI zLaICJ_F>T>ZR%cX>b`Lp9^X9!=gq|riRB4=YM{(WebCWRGGiak_ z?j6N!zkoDwB>bk2W$PtbNN3n0Co`jFci|n3)kEdfRz$>=>r&aJNQ?z9uM30!!V080 zrYp^D$!H~H0$9}aXz0yX%pEJa5`^w_dD{ zD-lIb0*rErECp*w_2DauW#i*p)AnaAPXy=;0@-BsD&r8uPSott$-)<<&ENXrOyU#> zW@ww~b-EalAbZ_RRzv+!@Gbc<6UvQQ^Rm~E)JFF#T)MyX_Gv2r47gLVcneirm+t;J z0{H?{O7e@DY0>~55nP6{^d#53R>>}#=Ca`Ex@(1+D=TT`2GHncE^(3MVv>Xp%#@?u>wOYXMspDn*|_YuE)boN5%w~Pkd z<-FsNZ&!iG<33aqu&TR|6wXV77)B)7EP@nh)uH%@=Z%g6))9Tb5B*Tm`jJhF7p3L8 zSz%@I4IUS_%<$25_yFNP{ts}B^X1Rr9B2G*y}Q+)wZu1mmCJd3Ou@)@FVvLL*$22p z?-2f=gH5j+N$R2k)H6c9ndZ?i1J9s@n_JEq<{Sl--VBL3$uVWZ@~cQIsIT+*UzITt z48KuyBN4rEWsWooLEhN+V9V{gkw73teMsf%Vb|LpF=Ly^5=vzixL3CL&0Y~JVpi_g z6j$0U0~trZ;H=D1;ns|D+of zC#?VP?STuuDZE0F zSS`CXh=fS_zGj1Ms9_{d)RI(O6&dCy4XcI`CCFUbT+E&&uET_AuEscoHQhuj3)13a zDN^$XjXgvJ&4~(-4X~>NoGg-KE2>6=OesT)qQT>6VyaCp4b3ji+FJ3xG3F3h$W<^) zsSq+c<>+pe-)T4$SD-&e^o zzmPx*C2YofyrZXV7JY!?pcbu;<-DL=k>RT=qlR(l=?DUJyaXS!m1^l|Es`Ga=*{k~ z0;!4E$Zl-(-vp&KzR#Yw%Sv$hNUg@EX{1qrn)vEByL(8iicV4wtXUt)CzKp2vtqcW zmsy*Pl)hG}SAy<~3W55jaT=#<4mKvW=lE zUhBx|mjjp=@st~jM{Aw#Rw#<+43p4AbA7b8b}Y0$(MlX7y9QL?CMPDc0OE{&tw%-6 z)0gPOUyJIVyrOTHHdnS5WJtHP_qkQSRYbKnpyBaQf8cO4kn{IMB-1&Q)}S6}eWx|P zO-bgNs*929d&l2TNl$_hr!2%@H@mN}7>*GCiFrCi^gz^e5I-hqHvg;dW@Tymi6R;H&%I98{D@;VFF76=Q8uow)98Ms zk)L|DjxFW&?Ri%{QX@oun7FLb%276g(nj9yOwna*q&0p-({_I zdsSlBk$G`QzQ=3cgLGAvLJ7)|_pLk{0$mCCdR-IaT@BNi47WR5<9|CsIg>+Teh7g6-5VgWV{?*`pb_%xlBN!OTL=7+Eu_k z-0zXbnzAxEm(Rjr5&s8O278T}m*-xn;yjB}P%aAvY6cF7JU*3!b9DcwU149rZO{-K zv&p6Gn%VtXf(?RbW0Unh8cpt>cC3TW&_gCVH=+FP_cdtN=_zH|s023}8Z2z%G-bvM;Xw2tTtsv3=i5<5b2;=|iNokVF|X-VX;*7Jz{16yptNu;^SzC)JYsrja!OL{|Mj4iJ#QzhF9oF0fp5iHad zg`3t#5HH|U)k5X1?tNx)JQYm3VaP??S96C6nBfW_J+OWDN&NHr0BGL`AbV#Reankw zI-nE&9c(!Y!_5^bK6EjgyKZO}yJv@L>EAINvFYCdis1F{n2hL_ZvO*)ZTMSp5aaic zS2F`*s}VF;F?R*pSMag5h&u(+!?EuDWAV=%k73-UWdLPAcd|T(yx^?`8K$c4_;C%W zSbYAWLPFHX9Y#EzFPQ-x+r@NQvDrylSfF&IUV^z_ zqwa}oe!ykdCIu6ulR6E;G@rz{SamfgV&)zcA*@;hwYl2&xBIrxNB$O#FoKyNexXFT zPlexu*7Sukk#2-Tus5|jJ`qNUetkxDeqkO6Xc%ts3(H@;mvTFa&Tt7RcTOS4n$Q-> zl(kHB1s3kR;9gP0=jIS`$L%e`v*(J>g#uaViAP;fH5dkL2M^#~KWfGu*fgfc?QJZs zJ;Jx;j?}#(s_6>T)=nwcI;*rw#2#$bRQY3~@KnXk=}4TPkySB#8YP}7_MZ?{=R}J# z4CSf8s#@al#i*+BP}`dm^~$8yPrdaeEbY)qD9qjmEq5uH!QLF&q*f@jr4jILEbE9W&Y3<^7!C6EOfZkS>DeGY41ykjmoWsq>H&1W@x$d zn)uiVA!!;Ve35U=!GnB@i_ITyHDW+PurhTqi)7e$G!`~n!9RcGCI5_)Mt#YJ_e{5U zES%d0q9*790-Ek!F# z_q=q!f2uB55f(y=vi@bM?R7lirs8f&s_3_Ju?4t%q0TVH=Ezm5_ZihsMlAz@WD4*C zIXUJ9lk)yP2%M^@#O@)e5E!gIsiuxJ03r2w^qqA`lC^;Mg$DnrDBE$n{tr7BxP4k; zK@qbG_u)uZo;<=NJq~ye`D|XJP%tVhhpFC1O;b?C9a%wr(YkvGu+R5DoVG72^zqtw z@thge0?ZA#{h_{NM$e0#G^}NBmMV6sDqhWf|EzB1H|z=h?s>zn<2nD{Z~gT8MbR)I z^3z`V$9uNlDnk8(j@P~rZ)kyohT1U7zmWq+1+Nu(i9xs%th=RO>O@%1FHYuqn2@DiKx2kizsdk z^}5{l49%m+Ty6MbM>xMZU>u9B$%A-xA%bd=JKm8wdkS|jgxV2FjU9JtN<}jeD$cBCnLFG zj@XogWT)SUeDOKLbkC_pla|`h+G=CSSjx)8!Alz&A_LH(iVt23F5dCH#zWUx(K(qW z@Y(4(D|hA!=wBPnaMAo25g)LH#ZnsGP!|FIn5;XvUGSGx9TBf!XUVMDN~jj*iD5;i zc&;!v>YOc;cx3YXlr*I?=`UF#LT965FV& z`Vp_mucSi%izATYXNaCNc|=!3mLbKgDBymYx4v>?G$uFKZJRi{#n;9=NLsD(iMykP z_Dz5N!)5Z+6iQxMJAJycVbvoMAg`{^XGOMNy0)U@MIQby?CB{Go~tD;dEE7^vQ^w%ZsM(J|G3fVYfl_$4dvA$GK9XZZ!UD4SF`j{m&Ri6i=mJVvp z;|+F)RH0TZy1TcEc&TOTTn_C?E_dk{+;eO7YAQ4&( zil-fZ!mx|jxmd)u@u!JhMRaUqV?5;YE|k{Xw+zMU4WcU?6R+}3AA%vH%Q-Tc_t49) zwJSBv@mObhCRZJDDl8HzhejMZ;b{vpj_W(2ZM`Rzr0Xl2C;ZF!6kz!Ud*ON3Y83ho z+Ofm=c`@?&0XE*#7-{xHea48Z$P4g=v;o4V?X+D~FueE#8FHVH zhz_Zv)2$yA=MDY&#>N*I8E$yKwR6+?GqnZqIwqTH zekzSc4U&|SLVCB!Ep3W?J*>w1MT^)#vNWF|FJYanGl1e+2~eZ?G>9VE~6$1eH1RkyapjL>T=Wh$o2`1_J+67nA$v4=%vE z_!*p7`LBCm6-`{tT|EA0W<^``Z)Rl#*NlJ?g6j*CHEQ^0&XS6C9CY+%N_Gnx1!>XM zM!tXf{j$;xmofxe{7JY^x%s{SvLqC*;Ej2skuqIhNTxlGUoYQ&%%p+Oozn#$e(aW^ zie`j#L_6Fw>$z@48@R{Ts`%SZ-s^T##=Q7sz3LUDZ)=}1s7%hees7TvjNY?rBYBD- z!0u$yOVLf~P75ku$jy*2YiE}~7-FUEH8rgT>tXX?hP%vl#NuleJmv*1XKB(CuZM=##x%OG2) zJieLhIfCERwbj7Rk96%7gJf4t_R%(vvEez!%%u+51aodd#3mQL>F><^wLf?`_Fi~F zw@QrFV`P#Q`D%~j&Z+jt0q6%E#k&|-7;|{UZa^L8>ay8Vqp!0bHX%hhE{=6$*A1di zWJ1FD2I|hpt9T2kveOS74$Dy$Yn30Afo;|vDe?T7b+2WmIN?n72liN=hx%$7=>@a3 zGt;ykqjTT4_do=yMhnSdK@^hGB&5mY_q(XNU4GL$C>Ba%2)xlEB4=O5W7UmEM**SzcAHvpOffT!$v`)UJ04y5x!^Q zW%N@P8xKW7-)A(XWHk(bf;cQym;7Af#|%)4D?^6M#+ zC~Y=Y&Z~Qd+{4MsTWCeup~IqXQSpyxR?%oO8^C_*`(eAvW`WT;pglhOKzUno!}_g` zQaf*^t@HkHbxRRg0nyNtKnKg#^QHB)H2vLbE*u75e%>#3Bfs%R)2LsW z2{?O_hiCye#u{3_GJ*jlZ&0l>_?Z!Mn!Wd|~aYL<~e`m)1 z{E1JTk2fr052+@ck1G`1o!$67X+!+Gu33P6;sdm-j!?;ER=C(KNF9Ny65XgAEhI1s zhx8e{b%EdhHxuN1*B6{z!c*Fz&|!3(H23~ zHmuT^$PjIHb?Hii{^q11vVWi*nkdf84yT}RIL>Ma!=CJ&vEkSIw=r&s69(zaFbXO# zc#PzHTVL)SeXnzV(3SPjn0f$S*+TRU9C3NZS1r=0t#nG@%e9-AvbQQJ20+k2_#X~p zbSl)E$-W1_$HILfG)G;EAe89Z_rYVik}qaw)ujj$N6tar5N5 z9N%^QMu3pr?CFi`U-`7tkP;Ew%?*(HaXHmH>w3lJY4QH+X|^9?3Zy_1f{k3uQyj^% z_3*n``?e;}&)jk}z13oebd%m`e0p@kPh^EG4$z3V*?L3|Doj$pG5$uDG1%UjqEJhd z&MA&&>80e(^9k&=oByaU8WAshV%Fg*kceYaP{h&PmuQ;FV+o0wqfkh|kMHAtzsD+^ zi(tIo9!}@IgQi}j6D`8Z$6Nm)S}<+rHnfGcrrgA&p<9m`;Sz9oAC@?F54)vIt)xO?@Cb}Dp!d{7dk3Hm zGr|m$DO5JjM`9N!;r<Fe zW_@)@bynoC-!R11Qs$n1;Fd2eNE$7IqPU%VdOvDPm2{4`= zXL}}-F_NoUSj1-AZBnT+ffU%0e_7+)b#TI)YnP_Wi6H1-lzOt2HYl4iqrcdl zy0N%LVUZ%G2C zrYsDIW=xkqHT=qd#uD8gS~>3I;3VJ8LfI&OJkd82sMYLg$~>-FDm>6y7gS$d`t)Ij zlWQVQdSiV@SF~Ri>;_0((kbIgC*cVjeu;~|8>8B;FV&fEhAYSuo;L}e8 z(WX8o9+RYxpVj*q!+eJfen96I(WxbST)ZZFTRACb$=3W-d)B~tFpuvoF?RO?=ha7b z?nVrB??Ln~3*`xZyb(-Be zsw;{NlpCu92;ZS@KqD0wQ|vTLti|K6on(RGVkk9O(E$!?B6Gh1`)Z{z5DP2+80zTn zL&MI%dPMTSi6JXl|AnFcW7GXtTLvya609LA!WSWQsZeV6OxdqMSrJhYW+QZbhd3Jf zf461G)=`x2;XM4Uq?9VlJ3XvK0aNJ%=;+p{8I3GnNUU35e{vspUr@ch&0YmUj(w9Z zM3`0+~q)h>Yz z#&Q2@q~vX4&^AdXn{lc)S;GA*O0bmC9xxeBS$hhmB#OhNqV#uubj^LK+9sKHO3*(G ztkD05ppcb;Hp2wJmJLubg91FX}+GH zjZvk}ISocD+&E*lV_7|i8B4Xt=nS+WmCadcwJKEDbh_u)C?BTT?v9_H1ihwJ~ z!(EiM@;^oSxM1LpgoQ}sEpvkO596uyLUmc>*rw{WFr~@sFOzGWm)oX9(Q&k%Q)oR; zSmj?Og*Do~`uGB?)BpBMUOAD(`({S%tHM07OkOuMqSoV!gANu7ifTdVN1fsOS>vpH zHq4r&B!X`!SW|mwqRBSxUBsAbZSywpY)cI1?B>G-jK081^KdHnp+kG=qP|wSk7s4} z2tx^MZUO;*QI$G26+Q4cOGRJjcKY}TJU|3XiHEFGTk zXU2}zu*w-qVYF75%yHm#opY)J-!082*`h-bXPl77jD-UStSUiw{NyDx!An{ImqNMytdE|c)BQw+P9^a`IL9_xmYZpoUgw=`?ST!_rkuWNytCPULpjx z<-v0!vAqfIobDTciiM0 zKn!HE;=1#;@DmJ{{n}6J92Vbx!adkpknl_AO?~-Tg*F`m2gKP2P)h&n%aKa$c3Cq}Co<5BaAYO5Z;0%i%fxJ0*opkA#};he!7Y+U-=*$Y6I; zrxyZ0f}l^(F3XMmsByo;q+DGNkw-@2ov66Qp~ktfJLWFfKGSf2gXR{Ds?HPb=iz0Lj`RMES1dp;~B5-l@#zTDUPvBg2eD9CMS5Q*EMf3+M zk4eJNlvM7oW)@&A#j^`v!OmqD2)p>+!^ar^70qSA`c^NwUQmPbD+{~#GHjGpQ`&ZMKg@`tk6Pwku+rKiZ|2AVL zClV>8RgIG6`a|#W{n2ys-^IR}1B%M*McUwEUnCv8x^)GG$;}syvW`}BujR#9X;+3x z{yO=|Q~qp*xv@DO2Q47ifD^3%(-j;Jb_W$2X;zS<*(KwGuqykG3JFraj+p=9F7O8r?eP}N_x2n zH63}ixt4pN8S9GUIBe9ZU%6h20QQ*c7w4*+!%<7=RM}iF3k*L%M;){3(uWtOs5HFK z7>|cbwhd5X%@Vr^(0Ac2bDeDJ)sh&hDYkx;oR4WnoT>C8@CQJ3<*mB3<$(hei#_(% zc$bLw>u6o}=E$=R3>ii8-%(#yQd8)Dgp2QY9aO(Eq-oCw2VfmL2lwpCFijB+BRv$F zH|eR}#80KJtXGP;-1LQj(Lw(CH$@hm3JSO;U-G;ky1rv%fa4Y29fM9&P8eG^VYW^& zatSDPz#C$a*}}$;vZT1$#kWb8O;%5M$}h)EBORiGi!BB4Jm$PCjvyyfm{CUf(aqC@ zU3>}5*s6|r0)Qv}-EdD57aybOE?insc-U+S?j`5R5@1D2hEW9&rFAd1*&{lLvqXhO`?6}- zl(qcGq2;nr-x?(hZDP<&UwON);UxKRV;*sI5FG7Ik6I4y?;R(KK_OPSdUv7HmIdIT zRLo8g0hI=4(7)e!gV1A!{Up9v=d)Osy6%)A8tg3@obqb{#?4GR{LMCT2Y(e>s@B(l zY2Giy*bK8{NE7ZL4hmjveoEPJxaGis{!W^nSP=5$vX{B4ZcDE?_!fy*F|7UL<1Ett zI@F4ZFTtQOF8@RUEKJrys~Uh@cn;qx#;w17WrBZ%=92Dy-V3xFO2=yU{6+se(MK5!PP$IUkI;T2avKK)_dR41p5MCBvU(Dg0h1v%giPNRgkL z6BL^?tR4s!$nT}!w%am`H4%@%QF=6{gG#B1^l5WTHa?cjX)Jc= zFT`np&$sS_?(R5Th9)%{<6cCR>476VGL{8a4&>JjI-oS* z^@U*+ZXx|HmPy^g$wcr|%IkJDQ*V;#)>)|=42Pr1>@$NLAZM3p8!I^RV!tOG*Xfj1 z|17?fnnzUz--Rn=D}dGMZH4n`Y0yJ$pp6jG;}x8J7Kw7DSSwE?3O7CuGO-_AeZM2{ zJ3qF<7(V3X%i)v%{w%nQNA^#Qp^x`}KDGs`!D1K73a+6tX}113sPvgRkzS?s~7BwWFeE)pV?ITl&Gr z)gPsuQY(mYfH=0UN?ve`w$VK6in7K@a*c;8`KINs@md%-?lN}-HeN^CDq*o#TM5&F z#*TyxVxVBn{eRlqkhhL8dq^Xv<^xD$Jz!SV-3n8T_eJ2(SKy5AVoBrMtBrNP0GJrN zkyS+eyfd!AV4ojtZRzV++EkT=F{FMM`R(+gPHbWD4iz0y)S=lluM1n$2>S`c?T0Q2 z(FXVPbr-UKHhjTU-RkUtP#2^tO=mc$RK3)FLj&>O)<2tA20Q2o4v0l>Lki2=mOxRJ zk>mrYWBBRP?h2#(nlNhDK}Gdd8Rz0`&p0)N$hDJQG#awMVjI4v;%sEp(sM23ZWR2; zU%a|F5eHUQN1Iqp_-aNMHDAh;&l6BC&fo2)b=Kp!O9v>Sx|UgUZ_P8LJi~2G>F=4@ z)#ZrCePXhyPH)y>rI;~dnBi&nHb9+4Xb3vGfX^YVz|kKd8$~U~u9W+|q+)&6I4B6W zX%e{-Qt&Oe|r8i9uQX}#f_N24B4?lJpyl?dqJWV1{}T5DJ&52y-@gltFwHdY~wwMxgG#JOQC&y zrzA@*V>r%sw1Ut=(MRSM*=86?Sp2FQUO%t@)1`CCrDSyY8hVjmgyDdObuCH6Y{5;O;TLbf|iA2=S7tqdGRXfk4;C z_V+gKko<>2jtoS2ijU6y*cz1~$a?UP>)`KC;inVj8h3jN?vL5eB;Si;QeXZN3DXQn zh2RFyTFKyLmFvGXV5=Bg+x?GKRr4<`Re=J40EMXDqaKIoAwv6ArCx~wl8i%-0+ViK zBjpem_A{HC1=+%DZ(tdCRYe8ct^F<&&dvl#LTPc#X@I6(c}2#>Z^bo!H4*uYP8Wo5eYApvOprg0oEgW0G&HBCA59>z* zAB)(czyf2`K_No0x%(Oz4f@iwifhqn>=5N%NX-Yld5b1lPsyTu&prR#9{u9xAbr>i z0V9crPC9??)!iK9CqCAYQ2ix11;DcZ6vrqUfi0BI4_ay-B(NH$$<=U@5QntH+px)J zZ8@}`JOGOOCRo)=rs!FLAp-db=iFRzB7SVvuHg-PEDVSqt$0$xS|=N3}9%m94GlD+iy~=+76;Z?yppf4$!mscuhM}g)NbT zTa=aS3k?T&Y3ug!MLl;IPsKMGvqj3y$J_1+J;MbK;3xWwynqaMj+0E$zg;$H(q#w9 zjO2CU5k!h`HJl5B#qOGLxzx6X7;IoQt9-8d*+orH7y(sYIBwWEbT+CSEr$nF4n!hk z0P@=1c=|DTP!U7iLOf^{zFg6FE68=zX4txGOP#rHpZIe{L;bI~wK8ecHqkZZ*~iy& zx39OFL~kEE>d8W-VJQt+*z^`WHWqj1#-zdI0g3pem59^M#`UTD=jXMG5N#d|-Ahlq zP0X$61Bf4LJqg~h9GZ>Ovd)zOIxfNj??S`m^h!#7CyTSVT`l3vt{;w25>bvw{05!u z6U=Ose!I5G>=5<(k|q zF8H{Pxu-o}KH=u{h=aE8jA{|WC1h#5^yjRspRtXadmfEYpc>S?ktBXmrZfX?B| zFO2LEqdR;)?-3!ZvMIhD{in0HVYEqN9=zNl;D5CMqFlx^i{aV>UzHFYH-?L;nOjt+ zIDnte91&~WIqb}knyR1HqFb*#tG3%VH2BBQ8&-CO7IXYB#0_emA?cpe5nGSg$P(bO z1a%FxZ@wd`Yi^{sPAV8|ahSb&gz+{hLe6;?G|=zGf@i2B`nVUwJ$j-6cwCozkqAlMiooutie097#|lp*+EtFigNiI=&l7>-)HF;3en8H9pgD)XH+rVH}IpdGE2~ag8R&5DYQ3zJ{xs)=YyL- zo=N6~oXFjy7_{h@^4*?jV?CqH%L~+EJ$HNAmd`b|vK@dJqR++R;(X1Zr%nqOTiHo0 zPqA@%>gPz~lQBNW&9Yq$Xyw?7jxc4|Rxc~v0}FXnaJvBMc80p%fV#|7U(0bmi@n(xi6T!U?{DwUTq7dgF;uZ4_4hqVi;^Po0>y!u2SNn}kf+te?3Mta*IaOAgpgm#wcZf_xY&?h7`j*!oG93>VH>Nf7 zeZnc+^s$x4=s}w6V(D~OyzZfC4uvQ5-q#Ta&&>)}3IU-{qTllaw^{V8e34l7rdR1# z*Dxkm`jj~+1Zfm?(to&3<9a{+7lC+a^5k#^r4)3NPAdl#*!3)kc50LMXGP zHYa#@X`D+LJHHE| zv9I=sd-gX}T;llzupjb%GoF!tev^J(^!_5R;|=mcQ763NLR|XXJDPBWt0kIPVvmJ2 zs9i@mJ<+A*W2pFPqfCM0=~ty=FQ)C=N2}4V(B6!yhiRr>w=Fzb5yGR?{<8V6JKVv+ zQ9NXDUtXDt;X0muL+e-X;=T3Gj7w&RmA+woq7<(x{it3X+cz`{*MYr6SRP1%A)mbr zw7{~}icotZ%2&ar*w39MtTpZV81hyVKYN~8bFd>T_XlmRoZ~d+rGvm=>gjnPA(<*e z`xJ@*SOC{$^5&S4{jQkhE-SBG9y^%$ zQU9k3u8lmB!xB8&d3WDK6@6mh`DG%kb*Mt$rgKDu-es;_+g|nOte*c(aoAo zb(QiaF=uS`EH}W9wU`Me$`aa4P~#G3?8W`;d^BS9z zY&YPZb!wIpJGH=2nw=}eH2)ee2NdADQ6yDa7LOjm_~+lWh67i1+~ zK!Z=O!Dp>8+#qXmN9X#Jxp1ooD343BO$zOHJco(G^`*;+0Lr2?oBTD=GCw0<01BgS zRBOFbSG>e})iOoxyH;qKMP$u;ag3>;Ghzkp2rbWKljw=S;_!j#=8afgjf{nL_sNOL zbmEcs|HIi^HpSUM>AFa8cemi)xHsDi|K(X+7&vQ771`xXvQcn{0}I>rVRp9PmC zX}rz~!)84s7r$fcY*AAg2EHq(Ahezd9$q8IpOCgn<1<(t*!%|TO&(<;6Cme7gsh!UaeDP4?Og3=Dz-;UYjX{*gVt8TOM-P7--dpf7<^H0ae z@}HTL92I?sB><*?f#70lbP7wWJt8M*XzIY%cp(u{DHEF&>-YH7M&R!W`RY|0>>>)H z&v2Yva5%D%C&(A&9~ads;a~Vs#e0EcW6v*}&tr2Ar(o9g2bNHRd324&WA(D(FWfiTQ==#`u*3WO# zx55XbjIn<7B8~vj>~tD(0m+P~Ing`HS$!=u32qHbGVVHQ3S1uB)2b|*@(lUo@#n56 zK1gAY{zPh;eU^v3skn$(-mLpT(MlCNe67+g?gNYMCoe!V-(*I6Q72}vCW0uIbyZ@7 zx?M``C9i<|HhV*^C*KZNPnfxV*18nMd*%|GV|W4g)CN&DESLFskE|8E|)-8`FF0``9V(_ zBIp^4v*Naj^Q+_rN+L3Uyfu`^$No4Bi!7&V!u#@d``!yPqP$%isX-cPERu2u-CJT> zf4j&M%{vZrCg4@G{{A|24gfs&|3IiM=^}{-O6|iBOe5B!P0#K#21!5&evRQztq&rg zNf`5sM7hD7qXCXFJ<*mbQT5p#>QF(QH3-pt!|Ui3j(oa?Jg%mwq$3t7V#Ft*p2lva z#ZJsTqd)onFLEzqO22O{3>a7`#{Z99K=c3gulB#fO`30-1Y=kq60q3A5J8(1oDqzC z5C~G$FL-0l^(?6|*0(e&q{CL*DeTxbHSQ}b@LTeG*UxVWoPK3BTkLjro5l*x3R`(g zpA}^u<~Y)@!hzY&^H(_;e9yNt9v0?%Z*zQ~V5nm{V8CXYn<`6jf+6XtS;;7k*7yoc z_)b-3bHv0Vo|%Qhv{28`Vp?yuX8n2j9Xu0H{zT0C9yry^Av=9G6p2y;${QZ?s@WNDrK%z&tvhg6lpDf9-xiWH2sB%GK3e zckhF^<8n)1@i6WB_vB$(&pYv8nQ4HWz5y(Em<%dhJ;X8O;Ew>gq}6f9kfU9jEKMP< z!tQEnOL;-YpEJ1=&N*HdC`GU2E&jC*%|n79SVgT;=_c1H%zorbT=a5xb+pL1vPENx zLSwDy<$Bqf?(|$t(IUFsU2ZF~Tp4=qS;uB<{Xu}I0o&-MZ^^hk^>?l**+Dz7xxI3B%be zy0n=jTaI%ui-S3a2{?c7ec_}r;#-z{29BJk@CYu`qDtzBio%r{ZB{Yo9!yvhks5uP zy4*gzXw}#{b)v4OoSa;H z%AAQ^3z;;yIw%_+S%xjUC32zL>==odvcWQAAzI^PK|9NWk*y4272UtHQI=m<>y(X! z@$Syto#KlCc0I*<4cmhx`XlCjV&MY<025O^y&l(KoyaTETiJshO`Ti*ab}){<6}`i z;_J|eK5N53`t}L1auC{VTXKDMl=1K{Sa`j=SbZrmi{eR>8ZuZk1-Xhx+!= z*bi;(U`*J(@te2$iv1(RG57X96*qY;9`8pAwqH?wjioNgp@WXd^E2^WK-yy?(_ht}N2OLB^rB?^loq=u3Ztv8 zO@u4f!3$PX8MU~B3hfKC)#f(h3g0<#ZCbJ^?UytZ5S>vTJI3Al)^AP*KE1vxvw!F= zx@%4@G(vTTdgdduLwAsImUI)VT5qL4p)cO>i<0Wvv1RY-8XId8a3OAvIDf^C?BrT( z4_DbKd+4sdT%ySMz5i4n?QFZy=?a7G~kR-NvK(E zsg%}v`zr7LC8)vMgogeFilwfUjchWLJEJmLCiU`0Pd%lyq&hO^Skp~71ab!cq#ZCk z&oeVMi-`jza|6w+o2u$=Tf;t*LrJe`Kjv_L2QqIW3gDpgBQ&^gm$Bx?r=aO9rgp6D zErkhwxxxM@-?wu@`m+>f>E^OQ=Xf39=s?vB9c#8U^YHkJv8w6{nO+Y)P)GG6?kS`oWh%%&5ML0B-;}#;O*DXpN<{63 z>os6q`3_#=Tx!oCr8iH{jIaQ*UvjN{?M$V7F{^icK1fl?Ci>Oy!JYum@5@s>1qo1k zF5vf$V&?k`ru2I)HZW!mOy!jUD47aWbcWFq?CBNiV_sA&fKNc`Og@##BmZ**Ne*TL!DPem<&CMvGJ_|1#%BI2To;_#QdFP(D`r{^Aarcm!yo`;6q z-Xtv6A!`I<6;8H2P9gZ@7cqX8F%pQn(G%U|z#zJ%rm|JcVxl5ouE z#We>6X#yg@KCU^lSMg-N(A6rr8j^Ql*rBC-oZ)+88m57@OT&YC?{87x??Hq)>}L^g zUoUip?zZ87lj7_L(DkC9aWQ@K^v1%uI*JAxz>@s(rPfmmY(LWN+|YT+e?9&~TIMgi z{?qJyW1e_WrC^MUYaKuH5=uvc`cmI<33IW-!Pb{pu)>@G*m*-GbK;P>sgArAmFqio zRgb*A0S^JBZyPL?IpWEdt7)CbN|OJ|8%o zchTzD%!j-Oa{RPHvkvf(1=JmpGG3DUO)kH1s6lzc)*X!z_vsuU7@$XcwOB#4X)0Ui z_UkQuIg>eOxx(xj(HiUWbawiQmMu|?bAjmU3Ee8j&8<-Cf0z*)G@)JdWjIHoDaXxR z*TeKF<}+?1&wF~SWg~CG+w|oRXUP3g+Tvo}jg4X#hQnma&B62XYmDy)vVUoB5&JIf zs=&I!oz2Ts3FL#-BN+XcH^WGb0cd`oFH? z|M8**77?xp7K+Z8l5P_$wL%9|T$w_+=-{hzwc3_KqRBsTm?StEho|p-d%In)^Z&F( zgtlr|jzM8(O^|n#EE*Dh^;gjJh6|IAzmNYOkIR3ioriBlalx0RVJFKaA&1bGCTYFc zn$npVnXKJgGFB%v8Y^q)PDAj>ZGv>d!c;R0HpmhAiliHM^#KK&+ly*`?H(FpR+wZW zh+|Vp)(9d%5z8P3qpavlOLK0$tRz|Ai~=CGVyuA$w+=ak4nLs;f(|7=SrO7DVsO?S zU zn+fb?{%@S85@vJ^wFz6COZd5L3ko|b5I@>HKVlYEPBQQjs&Eh)QpIZS6m#He_dwawZC)i}DxjH) zpmyh7X~;j~SSmr}NyxL<$z+1e3}{vf)Ev{dS8bu2AsDczLLl2tzAZ!zVi#~@1Y*R& zkupb_sGD(jOr-CUPw?dE#wR3%mMcqeAz3TN>$ILIio_aylHa*fZ6)iOL5Vs}Pped) z6r0~nr~qeb=&g0ia#xEplpnQIfFuuB?$DQBG0mR7QSDOmUe8ftK~L{W3vgVjthS7E z7^8_A%VM@7{MPZM>d(!a*VTIa?wja7qRN^uDbh>14CjN2acgtU))ig~POIEiT$f(T zunqD#O)1mHBZ!L=oU%!c$C|160-22~(}y8Cjy8+sSQTnf{8Olfp(vN6L{!au&D5!&b$0L2vt+0>t>K?A-ipY_=SIavDV~8cq*#(=FQFkic;VTs} ztFb>;beuN3XBZRg({I!biU&n6*3CJ3o_+~uzopn*%4M!#KW?;sq54iegrIOt;VZqU zmBU8$gdahrPKPNsb+R$3rjyDls}UAw6LjKUmkASs@mflO7_4j3sl(uW_dzr?uYp30 z9y8DD_pW=^IO-U670$T4*n;YoUe<>83q@&be$5|*8Eu4^Kf`nP-m)@>!5;rbGdK9v zwZGvGFrFms4qF|Z3Gr@FkSDh2Y7lgYdLKpZb>zAz6i=ktGjQruz1;Ir<9cQ1o@CgrewO)_wxAusLha@qL1Flh=x_QL zzy*5mt0Kwa^G+0R0#xl49_3b5ga?zn_O=T-ub9_ARJ#YT2%o+2ZW(v0JZB!!i++k%Hr|Mo z;JM4H%6moweQzrp$5R@|J&7E+@wy|rYCWO#^*|}H;Q@}LAPIc(Ca6m|NeEu z#nH&aUfABm(&67fHtKu-6ly-gC>SE5aEFEDSG1HwZ1oc~DoF9s$T+}dXw=_^6jH6U z#%E7{+bew%ll*7)vf=dMq2r%c4TCQF;u)Ezw?d=K>Du$o^=uED|NHw13K+r*Ss<|( zyO`VX{FLL4Q&reyI$z4@Z0vDcW(xksC>4kdqnPnLL8m-5&u1e&m1lxGfxYtKC<^kY zws_i^8;dqP&_;3(&Af~6fLe)!)cH1NYfk8ph)Fwv{FuzR&3{V%ZXSwc7*)!WSiZ?Y zNF!Qv*O%Fq2bVcxS6BDE_D%&M|fw%{e%=(RBk& zaaoP1O}O|4RnZ>q^WezX)>!rI&6&L5d_$CxOn8|NSclTj*Cke*D*y4Q@-3D-D&khv z0*E@S{-lx%gnf6BP3QA!+pG^Q#znNqYlfwJtEH@BgGyngHFc9Cl{{^&;y3)0K$gPT z{0k)%TJpXjR?#u#rx#tb>=@joKY7*S6+RV*-Wp4&BA&fRxK6X})Tzwt`fC&~)TbEj z=h?Y1{_Z(w*tH8iBN1`2%mAdu*0WLOI=Ag`H}QB)v%dC}M0$mNAw=sl!TW+Eir(fn zeSj=FUCoo|n>(e@jZz-QFWP!cY`t-iR6MkWTX;W9)qsr2NSjLFF5Z68tx}XQP-t?u z30=k){d(|id9RTs%3^O!v{Hhvn*eXtwVM@>shXqMVWW`LgKhPS9DvPkLL#vXQoWjJ}!4Ju5?XMjh@<28__mMLiyZn6B} z>xWmHG0V&1qclJnO0u`$6LUT)r}lZdqOLd?^{6gZ7u`VKkfFoQ%_<~r&p%)hFYYim zFe4lR8G+V_)^M_5WUX{JtlCRGztQSj@a6tg4;{|ODaO3=jRAE7Bd_sF<8)my&E00( zrQ8-E!@3@1#wtq2@X)r4b_rvimA10F?%;oGHhKdT_rw6aQuK;PK<$cm^@mYGFbEqo z*$}mLNjc?vsCSY@!=ffcUS;*=9M5L=Tq7NxJM7%A+SeyWG-UFIBWy%xUldNhiyEVp zeTZ43kPDLBJVG~}LNNLHn~jguN2j2QC$3G#-AcVuLN9g*_`aa)JWz{~2ji$+b1=~b zJ=jEFA4Dc%!0julAVepE!Xt7h+Nz5$fyGYMhq7OF$oax@f9jVU`AR~K5(6))N!OIY z^?i-H%gWVVZub&=vwcG6{H-96Bl!_Eh-A`T)H2|rO$zSwCoKAp7 z^>Gj(M}Zo^>N@UP*+%sW_RlBN^MZC1S5)p{k{-hk?4M81O8aWEIea+|XU_ZFb6tzY z< zf1dZ}=^|w@RCUxO%P>st;R+W_J-NEBnkR-|xw+}i{E0z!xl|MJM0?cp7;E-gxhzQw zr$-OTo-MlD*VuIgIzz{5$>xi63()HM%2WE|im2i&qtIK@{|U zBnP?cMB2Q%2^d|JoQth4(2Y*Fn{AF1U+^-4fJa($C2D=kDb_x;6~{d7%ElNlCbE}| z==!#84Gq;(8WDDc2y4;pVv6zEGx95x*YRifd40>fv<*`vsw)Qg+vF_!bukW2O`67g zkqT?>^GiTFxqgB-$6~4WE#`4IM*`)Ts zmB}?FRH#h2j@kC;aZXut^+=YT3t(AO_euLo&T`MjE1>=r`*B5zZz#gh9V-xgm;A}d zTu+zn6LM1=M-&nRZcl0p@5okw{*B9sqW?0)THl@7Mc-S{MgPP8#IhgmM5{k|dIlVY zWhwxLrR|GvatFsg+DU7GWQKZPo{%`E%|f8t*|7UV;#EIsm{d563_?dN(DO7u;lv3G z+6wKSCds=!Lbtfh3C2VE`T$5A4;(h(C-;lx@|1l=Cb>y?zyKa0oNM|Hc`TAZd=*k zmcs=IcQw#uISlQ8Kx0w)_m}GboRvK=SBO5hyQ$zHH@+a=VUlf9sA&j+%ZrJ^no@2I z+oHn?JGryjhvno*TUZ1u={DDBynWX`?^55YSk}n#4hTM#^1td^cJY4gU`zT<|7Dy9 zq|NN`=zaF-i<^7@-uHa6_u2DE89?T{8p*p%Z>hBKgDMcRH7VxwQjSS~`WUgzHK^z+`Pyj-waEL9C|ee>(=~@ zNDb;j-Bu{8|Km;7S*DL-o}Y!22atg)3s2asNZsC7hjn?TRIfZj#ZT&Zmdh@;&J^BU zk#6I4ZJceY72E zDra@NP+@CF01j^q8H?{WUI@kyjv;%V^%ZVxmipui@h^pktH!@<@&Z*EkP5BoYm6ReE786C_^m4gvlUgIP}+er zUW{cZn;H@>iUe2u7Eqc^+d=)1DHGYwL_?FTX-fdpLH&62mJ8Mtxt~~CbhZqzgxb0a z?w2^8#q@3qj%@cun<6+ZuAy6{%AR*Ey4Gilx8!_)?04+xZbiEPu)1}c?x~})cxI4Pc*h~Ut`S+GlBkl^soCzhp`7X?EIi;4VJ~g2L`CnUh=qe~JGOF~ zDsD#J7=`ShE$Y3iorLyQjBT8}jBr)Xv=sMaBzB`!*OAE1NX_H)vO|Pz*Fr==n`f%m z@=|BeBF%C%{HLTqRrKC!NMG~dkRg^YMbwQ>XjfQ^<>^ED??Ir%jsDhw&AGK#jYLoZ z#!x1Q#jns>w5?ezoLB3MX9WVYr&>~Amp4g-8c4jF!K#jHo$;y^^m$X`}ZqBuIBWjfq4ho3rjRSM9+$B#x~h z;6BNrWI~Q=LQYH%P^XUkiP_a)EckuP-uSIdUBcu3PfywO+35HgH1EC1-w!gw`V* z&#&ABSl5fbJNm{aDd;?o@kQ~ChWUP_&(OU(km;~pq6OpoIVLs<*XK$txN&NG zyWWhBcNigkoQt?tk?gXIQrarHYNYyK^~rkh{xH_`NWK!CLoP}J4~dIQN0&itLK~xL|CoIa~l}&j0(yX?Dn* z;s?(UCIBAzkdz^IxueQ6N>_0R?ab&|92~Uf&j64E|kuGbr}+TX(}RuF4yyM*OS4 zToi+TfWdbcl8)Vrm(5<(?|*r@y#W#Zr~%T8c#AxXQxv09N`o$rQaI9e3A>~{SUI7t zcRSuF76klqT;A!Hckycc{CmF-#G*+cS-wAxy?Al29v%o)uMC+qxDj`n&<(`aJlC_e z4h*GgWrfS5pYLWlI!uVBee>6Ya!(|VT^hnUJNP0Xw=g8#i&R$MPfpYLok{f&-W>Ks z+n9CA7&!LJJ0ZVsIGQK!x#K=>Sry{#K3_|uo=diVZW#pLKi+$(6d>p9fAwOn%;Ofz zZ%&Zv)6nOeG1DNp*5C`=tokHr=`$A8PtwU?L;CsGJZ0MkmaZw5_9I+sbC`sTK$GZW zx=s+vU$B3VlsS~Qc9Oz&bnFF2)@bVAOZmX$4LuuZDc^(t9~c2ujT~+ND?L|T*9C(M z^CNkEV_n~w99PGMg{-*MN}H5D1R8=WWPemaCA(CJxuRP+NB-$>BSrOv(eL*^0@o)p zz@=Q-9H>)HT+Py|TkPdv&Lu>>V@O$D940NDmo?(lFXoR(~TNq_VGG@`zuD01ds2tbxDGvn5E`)P)8A z7Qb4Njx_`pc257E+rH=`OwOjcxiSuR6 zJVKWh2tjQ*Uo^kSB;H;_gslg)iOUU9PGQj_mC^lmGib3gt^S#@y}R$KsVs;eJz*C` z=U&=Yem2dsa5?5!c~mm)cpyELl1d(Vm5ZennR&?AUc0spW!#5ZyN%ZP%Vi1)@jJIw zwWo855qHk7y!32`t)Ky6N2q+~Rw6wKLrGQ?0zdbxrm6uTC zyn71uc;R0cxs&=IW34lrJ{kq~PB^+o^D7>bX;+lu^lZlk%$oSNnIXIE5i6f9{d&? zFX0j(X9~lrmY_ivuQ^PY@sB%j{Z3-xAAtRa*6`7QQ2C6HgZ2HDV|Q;1tE5y=jHD2I zeDNZ!=$Uq}(DIki0_N%s?JYkUUuh-yyK{L-+bBIAt;_rR^X-05t-nD&$ROtU6MY6xKQGuXMoCaiT_-}9HswQr>HmYX! zF8}2^sG+Y4B9;l*AP!0h1%$*eLN9WElfn-z{$nX6Md;Wc0}g{Bbq14i9D|lhshLQ2 zK2|sJAJZt%bTdOC@2!Zphu$XIF=g1il&$s2y2WQ!;CZ)aaqsi*2Xz4Z_h|AUWH@EK zoHOHg>kY~hW2d~LwEMr36b=$qOhw8|QTsPEyb>T!xIk*M9Uw^v`?xxK1x z;%+Tk?}`Ab4b8k3KK)Z0A|6NgHhU<@Gb$B`m;6gP^zHkH^wWYnuqV+_e`}VpunYYZ#1=PU0SMtfCC*)?l0Tvgb|p!|jSi_rSy6iivPErt7M@s)#Jeq%>=( zpW^V)7_X)X7}MesP9`OOx^BY{SD&ceUE5nzI#Fj{(S!=xSM{dttHnmM^=V@8(5!^6{=b zTpsyImNgzeJBO(jFy?}YD{K;XJuQGuCIpF1R%H)C2TA3AFNDNfd zVDKn0hs_paQ&4DGfv3(T|CW>5HAIUos#Xi0*ONQyIHD*~r62DpndKEXkHlld&NlI+ zRmwSjYwdS^>`wHXs>Gm)e90P-qkRpD8aZ-d%}`;Uuc4z=2K$uDls3|$W0BNVp+FN)$=W&}cOV!WlFPu&H)$T*=tM>GeYYkEgTy$M5= z)c9tV4Sxh2u=wddE=uL8oeAY$omc`zOx7Mo*1q*6Vh!->AMM&Q*ZdyBXbiikst=U! zZ^}KyBeP2&f+Q$CKzQ|+_K$G>xg#bni*S^X=>=_dJg@87`}JanAJNcc>$KzQ{EK&~ zzvy2u>W{%2W~4@@>xCbZ6l-aJ#P(hkp|bOTLG!_p=!p-&C@#=!N5S1h!Qfi0I;C>P zLb(p(^BT`9`PL!Fe$j_oVKkH^QT97K$K~tq$jd6GfKYk$1vzeL{?L?O-ifCmcnUphju6~Iq=;s&vvj*z(p{4r0?DxiU;15O5!z-Z^kuaCrU@01v)BfzE* zh?qqdK^4RrRDY+{D^ji>`RMO&I$wL~?wG`eU>e!gG^1AeqW@Tc6&GnN1E*sjc8T2Y z-umV3@)Oc#=@y7x4Y`1ayX`^P3H#K20W=VD4TnAc$zrf8D0=$F_cvhuUko%+y5GrQ zpvkNP0_^^`Yn-Z97XO7fB>xXYvIi2{h+OLBN0uTWv=pRGgp-#N5n(wXXM$GAFD}S4 z@d7|-A5F5-z7y=Y?Jdb;TVaPRi_>t3VQ3;Wrx{}RV{&l_f^VlUg&-3^H z#OP6YPc&*9TgAP^s8(Df&F|S++4tHArwA;# z^QJ3LgJDiOsP&?@OoFQ-OT?AM@Fc2)%Rq^J8l3_g%cX@_HV`ZZm2;3A z@>3H90kQjwTsc}#denMl0Clyjhx3448BsMD~znLy_$2$mt@_D`M0(l5NB7rd;+<8NNIvyEHZLoQK<~6){M+{$a=$#wH(MU1 zz0JI7ySVxI1WJrLYi+4ZnxQ<5(+8_#CC3409lvyHT-TQOTbCCS2A{hHUEGFonPouLzD*Su(GHH*vAz3;}v-7`rhzqf zp#I$%3Y$V<)C@#mZsbt^L0z5uibKrfmyYz9fVq;x|8t}tHS1#llX4FhX?Yp4B|W^d z(yA(zz9i9AAYl)sWGvNeqy%F}g~uc0;EokBK<~R#KyzNN6tqaOKDZTM z1WN+7)&W9!A`Lj>w;foTHDy>D|H6iZ(S_x``LBIn7uWxM5JRglvpgVds&>7covvMw zTl=B35!1M(Jv(~fpd{nO^j34DL5fn4hF0O0?%A}mciNg(~(h z_|WbT_HlaKXMOLQZG?@Q>(jy_SQr_ioV~yba(24bGmMSm{V;CCRI` z4R+xvu+5u_fYJheejqFX(g0@;Z0o~k|98d9dxiSiJuk##lc}~ta=XueRiQ?q*&psf zo44ZsM3<^+<7WF`%JH(Gsfzm_Q}L!ECOy9D0ljvlhr{yHqK!RNXf5^xi;~dhHz%Fxi{88IQesqiJL4GFQ7YUA-Saa-2>2Zj* z9bUU17)i!&irW$2&T+61U++cB!I+G1c;9Vw=cQ?SC>>U-)m_(JihwHK)WFMcY^g}t zXH$B>J-CFf=R$M6gBJZwZNLPJp(Sk>J`K%runx0X>sUh)a)j5~SBb|Y(8_4w$cj;Wa;d|^coE6q(GzT+ zQB+2kOJ?xHg`XBuue6TdJ(o4(rvbxwrhf9lk2FndC9>}&OjE>7&bP#>PQ35HmFPLO zYNMQj(dJOmMG0d+O>os4p5Uic)!q`1_+u!S$?0e*blIiCl?@5)-jXlI_Nrn*`7VC7qTgLODSe&mGq0n zr@Oxu?^i|0Dgk{!r#Pp^m3*+-9(J$QXKCSFXXVC>1P*y^ojHr9!AH+VOPQ@z z@!gVDi$5!x%6!k15S{0)YJo<pE=D0*8Xf*T9NLwb?d%oC$}8l=7Xdqx3x%4vVN335ODIOoK&6o#$Y{Xenhr}qHd zvwXve`}OCfTQT52Sb5txNmqdkm2dC=bnBVwIoI# z5o&8*A&QXxResvaU!kF;`vuTTm-IgZ?8nFq3pMg-UV)K_RMMkh;saz2>ZrZ_N;?!$ z3cd7k%e|1dL+q2kLq3J>H9iR#QVP|i4lK3dhrr)6oH{tg=QqG~o>d{2A4rTJDQw#j zTTevl_dW&dnTYb}2TuMbjqT^?QoR8eqyBw2NLEJ2a|R7kH&CwVzyEYtN77D1j>q8yb(YI6q7xzZ`;YDMbrSC}IK_VZwavNad0sm5pXxIuU#CRJLQ@<@bQ8ak)|9^@FYy|&aZi@og~!|?Po{YM9##egtf#; z)WUwu(ap&s3UtU>vy7q6G+#}Jq74KDl}8FAvhe?wGMN1tTG|=kOZ^c1w@P#Xl~4B@ zUlk^QbQQdWns0z|m~>uX+&Gz1n%&(OV0-$^X_F122U`wz`+~kIOY8-rj5;?p&8?Qj z?k(fFzv>91oRY$Xzb>@tW=SFqtLvD1i8vU^WK+)&z$M71Vad(q7VV0zz3gk4XPL!3 zm=~ErLh9dHiAGs-F>W9@VE>gdQrlj2A5fZJ)H;9=%OO=2qJZ-kwUi_lR= z@lt1z(^NA)3@8?(6*;!(aL?jzG9zW_r9*3%jT()YiL~sQsdlpTHhHd9_KIyyqoAqZC?p+O|$+vW{ev=y(?)DTaGTS!GSMD(ZRft zp8niSn^GFe5P-U5COc^Lq#%xFwN{L8~-iL));-|kP*)?+KXg_68o^83FsVj`RER~g1= zqSPHtu$7_ot%l;6@39prc z@=2~Jok?Wim^7+&_q5a`R0e^~b!ThUU2mu3u>fa+%P{BxOmmfH5tE4%RqDzqj0uS$ zTWS;F0NeRJZaMPzkcCkB*Vn*Nu$VRU36pxUJC0=DSByHRlGp|@O(L%EWjkQb55#1G zjm6L0uTD81`domptnn@%Civb_?ETr+TO^jtdHye)B1239dy7R%n>%s#UVH*X_dl7@ zf>g#XsVvs@dn8j?NEGV_For$YeFRa4p6#;}EjDK$=OxJ$RueqK5BBg)g*`5MWs7su zy~G5)yFmTW#~`F3$ghI1-XJk#+hW|1Z3j0 zZbheI4;ZOujM^4G@n6NBf8yr#4fzrw^tFmV4&$f~9~s}}T@^6+Y^q1Fen3x+dG3Bk zL`aTHR&3gwuMR2#tBCC^NsFPyr_?<|5pzc$)3Q^1h*VTL>lTw#{ZivIpD7FVK=Et5 zc?kLCn-A}QC>nz3{;Je7?@SFSc`NQfeQUq=BlJx3POok206hJR&cXWIhY%AK!w(0= z@c-NKNL4#fCtl3?AETj>`M>6w#(ydspg{-Fcp;2NE-JE^K=#SSYE+2y7F;P45&X_# z6Ccp7?M#^w;=1?zUhxj;cP_dGGiWt;KA}nidNTBu#9rqPTc0=&u34Dw*?ZpM^SC<8 z^Z$H7=m&g>}#(#f5l$xC)vutYjKeXX8Sy zzShYHwkhc2UDP4i&2*kJBTT=|+x5^y_=k{8##fud_U}WZS4u;t06KP5#nH3tUky(( z2WFZ%b3lmPHpv0vuy^r{xi@cna(-I;`Zyy)<9CkipP=s3K?rOX&yXd`0T?nJck!VW zq4}AV4j!YOww0(Q`?OYR>VE8e?Z#KD4BDL)#w8<58KxgU7Xxx;%|fIHn#E6uHK;s# zkuK?C_i03nuEaVn;U!P0j!74Js5Rp%JfX+%FefL9Q#y}pqPak1HREl)ldxR)xuoA1 zMpNt>Qr&T=2KBb@xn`Z)H>*S2rFlkL9$$F0+}HKza~xM&8wcGrH;q%zEG4JT*|yjrzz{3W)|DKiZ|bB)HJg4jw|P|2@F#*0$OU?5l3i5Lf0 zhqfd1QQQ9K4;lkI2pPP|)kDfkBnRHpUt!odwfyY@bl%4mcHKMxHeUfw0bXAI;s6~9 zS#YEI#Tl2q9-)wn#3--M-8M#OFb-9+vh5;3zQO;h=l3m5X)hTKerFLK&equ+TBx%}? z8IiK6v`WldnU&8w+Rm~^zM`jzHijq~Y=Z@^ak%pbP{RMW0|1hpTIX9I`+8ZymO-l z<=6GNh+=TCTWF_p3RC5{iAuQF+Gp5;JUIhbw6B8d@q%1?E82W0?_7)JLhbaRL;S|a za(6@y61qjG=@Vc7%0vh);o!Q$M~6>gng!Ooq*im;lY|9tRoO&#@prIKG<~dc{1nBY zsHQwzd-S$R;63!-{AoW+rCQ_KBhQzGxlM^>k)LFFkq;n)^7dMRy;xolL6bNN^gC2t>U%qm2p>8B2SG=|?5ixv79 zZ?lIIakL-77QQ4N9D)w7$tqr?n7*RIn(=B-dkYC$yYYY$+RX~P)qn%D*C+czYCq>9 z?7Br=l9{#nrrxSI;JO2Q<$&EQidPe(=|<$5<#K8pge1g`gmf{i^gK80szjXEYsq<; z5~VjY_{ROzElml(ooR;!LM{}(-P};VNy1->IUZ(({DupU5RHa=@XhZ#q*c$U$lhYs zyVKM_yf^qbkRf4Mqm=R24+PoS_L$M_E*21By`I&3%hq#5dvhg9@kP9BCg4OTN_@?Bqu>-cuxZXCZv^RFGhDQ_m}YClv4T`GT+ZJZjlM`4ke zjn8G$DI-@K%_~aZmgZ51;zX(x*cXe82y}zv{mnA?kadgk>>OPKDC7G(;OTFTymRi} z-bP`?xz-(ovPQy4JGml^Tpxmd0crhOUu-STX*r-j#inX%-`bV&$32xv2PB~yT>|p= zo(njborLt)=nNP_)uX;$&;`AN7z6L~b?22vfVjmjJ?-;&m{jhZou|R1qKO(8jt~zF#p?rAXR$@kN+{4A*sWdiUE+Tff#oP8md3tMl6cp)TuOAPo5SIk+Z`^~ zJg2kU86O|_@6-Vt)#3ZXQ4n?TB!3YxG@LZ2bUT7=|LhC3+00Z6x3&)1)iTPuq*No} zAl zcU0M#Ka0;Q(6mpW0^LB(QnGeDkuPm(+Y%_jnKiGh+cXG=aigurcSKlzXBJAxCI0sU zq7mI)k@|+F$!{DQcS>gYXxGbASFEn$IX<%J~IFekc+6rPpq4+@Com7sw zJt^mBZV$_TrLAQ1p4~BmtpO4B@} zRPm?R>yzNEQUX8kM822@Ijf`A@+ntHOA|3u=s^t3ilC;ae`%`g8m{Y;LHn@YsQ-gN zM%mfG!qMqp7dnl1FPtGXUZW8`?X8uV{ovpk5lI=MRSSq(Z1u={$I1 z4_ce9H5WH^;De}E-ZlAyptgBF?SrVcgtmc@^t{^y!D6kOSdg9`|H1V+!qU{$7HGQd zcD0fI*!j2n_Pal07mg5|7VE{#Kk%bYTy_jt5B@S;d;oc`;{=voYGlT zU97d~jF!_}qZJHESieJtA)f)(c2?Z%bCGXctijICf@UW*^-}k^ghg|=W2>mbb`774 z_RyREOgxM*-Z15CD0`BoWtS_VR?;VNR*N4NO@auKagB&;!`cbFSS}VF;GP(9YZ=S( z?_gAJ;fwBc7@Ujd8gIwKMKTC8HVRS~JStUv{IAS|U@>3Tl{Y41~nN5&__vrHB)pa#Ku(EHoUhGy}ZEuCuHSG2K7kN{qY! z9Xk2*G5oTKfDs}qO7H^2H@XvvK+A$flsDw{S&Uv%Gh49{QAV3iL*Gu8~483c2WW1cn?aKMbWCAfCHo5a6z!ZeJ*n+;)(gm&akX2G*j*UA!R( zijU$GD`)bGt|BOI3hcD%lXel@b{XyAvG6n%JdlE6FIgli~EWchzRoG&j7zPv8&uG$Ckm!Pa5fQr?gnEz~z|Y~^UfIKv3(hv`)rE=$x;D6EX9k@PDFixa#QN=c!TWz_of(f(Os9xk$I!WvGnlp z`g?3X;YAfz0teiQ9are(o1~c1TLA5j%xii?4J%Q^AvPa9)gc_yYqK}3K*gVglY0G> z2@{;m&b6nW3XoWstC-)4vxN}b)mhsd8?^M}K2Mw4GOuVB;O?1FVk3hAhczI3A0;aP zmLQj+?UW^l=3I+^b#qU~nZ2bZ)33X z?mlAIOsBrfsOycXB!g}jz_O=vKu3=`2nzC!UOWb9-LPF`up+EKiU#cS;!E+jsEsV! zpIa{W&kL5XSCh)RoKJqM$`gGH%BmDcI;)EuZ(ue#vcAI5C5mNjnQFud$RbY5E>U;e zI`iy$icx&;F{@~#ZH4I1JoVQtV?xXJi4FA* zn@U3w0898NA>Cd){vw_Pg+zy&Kc}6q{ zsZYdwI~_3QkD1&25;H$C)_QWe#l+SVeGSS;ba~|eY!^C^ujqAn=YIMM_+eKwcr*%M zkWzIx9nd4Li8_~7ZVhf`AaH96nG~H*i!#piy}x2R}UpMIu2}#VHy14qqU4$?Yn( zC@-2QuRF{;o9Y9TFK(W>78TcQ8O2r+(HpGtj%0C}TiLa6r38%RykG)ZnFuoKn{jl} zRz#8B9|$*Vs?$<;Qh~XwBav$PmA{D4%{IyVfkuP$fr8x!xPtk1nxpJs5Sny?U|kSg4ba^rqKW zV259T*@=2wmLiDFbw`=LU`6Wv)C`p)125mYXR!AR751=eJcmxuzobA`Xa2LN4ykL9=ef+yn#@X$CD+Bu0xBpMydN&K_|HCv?x6sB}M)R%x z3XhBu)JLYLM7&NC-&>E}DVH@)*oSGoY)Dz4kVuCmiDB{E92iq$zd@^;cx5$LqPbS| zp3oA$4!hT6!S^iwc-qdqtR=?uBd3(0sGZqq%4@4}i~H~E_2u&ynZ0eK3&BvJ9G)&T zW0vcNcfpCVI`iS8s`3IoGkP@&r!ZzXoTEp)+Pt#~bT+$qhPVSDsP(w(#0-XWWad%-p{~TB#n>+6pB}3*s6h zk+qp4(^Rr!a(Xd)WCRP2V&VV-?XZlE&)DZ>CT%Qhss=J1FlH`jE&5E-Vp9n6|&i~;FUPFF}9 z4p^6?h)tmbcXg`LH6oeJeHY-*l7bd-*&HHw5@IIW`e$Sn6N!wy^<@Z?Lo7(=bV$7H z(J0~Pxl9=*?Bxfv=Sa``8AxxzY14*NiC-v=Sc^>3X&4?BMdtchFL?&Qs~?mE+l~2U z=SA_PUpNN3MU9f>Ib4?zrAsJgDo6m@Nnk&v8X?nUCL3Z(Z(tn9;9lA?=`q@AyR>XJ z&2sxoS$V1l1c1A@Pf|1{=&}Z*s0i;)jnXjctth ztvG?`TCQRcka%P@%nu2(!<-%|5$5c~j)*BSMCSI+Yhv;KPa|3-w(z;LJL7=~Fx##Z zI8U0{cA8k+^G{J)3A-IEUIUqo++yo%=g_uiFCZvRJPjoAtAsU1oFj?Y4J4=9uan7G z-HTh;18FW02}hycly>CEQEbF5L<}q2-VtIZlgWlpna;GW^GCY!WJy+)0WG$(WuDh# zoieJ-_~q&*Hi}*(p1B8;0cHmMdOyt_^;3r?Q?V*CLF)Xh0`p67sYiBX6t$Ule_6Lo z*_aH+fM6EqKye2tH~dfC7z+DTIayPSsrEkI9HTIWic0qECQiCJ+jiIEui7Qiq|jUb ztL=M>;w!luTbR6S73Y@P95AVt{lc`cm1NYb!D>n795zQb!b!RfsnE6v$E|@_#Dy7a z+L&TjTxb~`(F;;;3u4zxV6s{Xk1-)8c9(T2I0(2ZxNa^*ROx^zI#zbd+r(+(*NjwA z_+Q1?75wDNv5GFm6INC+)b2G@QkE8_7pAXCl$}O-^*i#C&}*8f3Zzo~tV<>B6ddhJ z?4Kg4f{#WTrgT(rM=H)8I(IQ&j498mL^SQ7{bF|LUn+sE2U~Bh;KcKKZffn=cAu`G zOIVDw_-*Rhw>;zrczw2utRd+E$`MW4TGGtpgH6=#%@WN&<<+s@U=920s+uT`O&_US zCUvhHgOrp0%JE$qM$(zf5O}o8mTr+L_Ds;Da!7#D0QaqE>Pm}w& zdy)PS3N6&@n8P6Bx>4;wao!YOP9!eG`AJ;(p#`}cuYJ-T0*=EWRGF{MaaC)Gm-(Zd z*e>x8zIXH|0?K#$q#Ix!u@nwyrI|lQ@vY&7s~hW~{uVdqTh}7{jcmhTTCLDNsM9-} zH=j^n>`CV95@lP7h)v8>>EJlyXZuN8kzLo_d@k<%ifqIl>B{O$25$t(w^Y|DkJ+zJ zQuORM`OgjwflhX6mmyuPrM%i5^ySu!-UR##6d0;BF5!+OnAExVoDVzoTvMLOjWM8B zP~62ySJ5B-O83XnDEBU?ZP+;MO5(-BUf-%MDRvX+j=m>hPFZni$Q&i-cd_?rJ40*9 zHS?mbRFWcXY_f9ek~8yXRg33cNUESqp${V8x&C6p>0%VY)S8ylIr-ZJF!p5W+u6^^ z9BDL}=$@e1ivS%P!z(sEDC@lpmaP*jel+|OVvzx!WP5BznuS-K+Bck$>6@LF&$(|X zyO19r#*nMe4y-5F&FG&!7h_!w#oM)e&6W!3OiF@1fs3*ng2eKzSPQxP7_i=Ith>G0 zc`<4~*c5Aa(tnm$DZJdFjtl9YKvm@9J8K0jsbgA&@q{cO+HjiLiByqEPioz8$Q9;BmaMIG3YjF5 zX&T+PMbI$CfJIoxT!wmr3!MUd_E!o>IY+TLCzQHD`f|Qk0hPAJ$+sAKfl&Y%>=p*h{pN*!}chEuHid-Rp4-Cq}DKcdI6;al%>O#W9YHV9w#_@(@@V9gGf|= ziYnqEa%MSUMdKHj88!=gH6H6wLcK^oDA%)!QkN&jyuGgfq+Y}hQ`PxIYJ5iz#Oi^8 zq-#NzzY{Kq*0Q7bX5%lReOi&FEO1X>diYgM{K%ty)A@0}#`-`;gijG=T@g0!igA!gCASO+vHT9;=iu7ny zB@zjfWrC@&PMIiu`cmE?G1g%d`?SXcWYw8*Kq3Yhl_;}P#VFT@2+mg!M>9PK*I&TS z$6KY?an2cW*w(-~F`t9n^n{WdKi^jMBynqw}Zm&CYFjjo1|CLTgup+tHMvE-;V*S&8Y_7UbVg5L+` z9;aD!n*0$hCG4Z6W*ezqi$yX2b=)d!DWBB!uAvSZVBq(=$5>n$5HEwvXnlB+;jW3! zr;@6y48(__i~FnTLhkN9N&k(UJy_qhos>6-ojp2mALC>VaY_T#EWNXfD$EQ6q@b^b zOBE~xTc_L}W5B{=VJhGzC|(~b@UWY;E8&Kkt}26oD6J*vYup#j&CHDKLO)`Xlq#Rs zXy~`E&t4L&jrL#?X#e8D&6|tR3T+6Z1<{kd$TfBpkTu+ah%> zf_5yT$J0A%ZB@HPuZ}qI-6SO;?%?-9d;nzk_^e2`zc&V)Ic`|3tTLRo-*JK%`WND#*p%3A1QDScm!Wc)VHozq#mZvEBF0l7#Q%0VG?X90KaWtVf z9BGhh8okE?3|@gegtT)!Fl-XS0Xd|eEMn(pq&1$7-1xNPPB}E8&%2r>t9_vE#*W3P z6ZMQF)rgoStuo0D6%amumz)ei{9wZcj43zUUZ7o>|qIt zh{iw?)ryGfSrOl50_*I+d#@!B;l8j78u$@06XrvP$leNgKn^ zS50LytSTipxcnDtW4(&Di8bfzZTC>hilp;4NofI-YxtrMwRm9ib7gWQe?_*>UZ^)B z`;WAKCSF$rDr*L!6E=NC&pN~39=1kpsfBELSE!>0c}!tRPbBKj`zB55zPF(=R$x9_ z6=E+4McsWOIzG|(lyk*NeEw^V_=s!4*9ml=$%6SGa2qNnPR>HsAT|BJZZ)c!%JT}S zpSBotOL>7B8<6E|=%ha)q;)k^lM0BxGr#TpwrNuqYIIJ}7Wa#pWaB_5-dd_BjeLbIG|KjAHBnWVThryF{X4JX;Q?cuAT0K{`NV8g{W<3{; zM%T_*y`VWaPJ-28t6f8S&4fEaPw{s&|99~Xt!*moLn{5M za(hX@d9JA)8sVu2+`=;tRc*{7t{f{B_a9NEJq)~VLkTIzP=L*&3I;1>-{xKD)Vc8^ z{i`TNmtWt4xTXxk?hsTy5uOwO>dhQ4{?z?R(G1g3NR1??C;n9wSY+W<^Rq3P{sKc` zfRxIR+b2IiE8mv^SF28^e#M@u-*1YVE&GAaovbyL^AeF)JQSwgQt{C4f}IJNgmOtb zFsr+qynE3Iq^1@B-C0}Rt8{>RpM;Hx@8V1Gpo7w|_fIvp=W$Ns;ecoob@xEaA;&S7 zJJ7u)fRwkL${Q-$wGbD5-%;hQr6eOvt_9gXc z^yR`F*(Pp@>T2w@MnEPg`c2(Foal{9_cDs`)BuBjSe&p~{ayC8l#Q14%w;;&&}2So zBc%&y7xA7F!P9BvFNO*m#CP4%pIh&N7$)GOLFojTLmm=XEYO#ZAo);495M0ikUnNi z&>h;9|4ASbX%Kvse$$jn6WjX(#4QU^jQX1I3b`NboczF|kuu2IL3T2SnOzwgy!N^2 z-lUeJZ0zBQkaJ%2ksX@yot-$x${?pVU{Bn5$>D{&;hKG{qhI}4h{$JvhNuIrTh(5j zJz^mgx+{1c>4iFhs_S!?UR%BhBINko=su6EvRC zsL+)m)Rjc>xLmC1=ya3_NYTUP&2ww_+@`f5v`I$P%E0G;j6IvTQ^V2K<5bHj1=-2D z?*qd(W;2wo@Y{m48b4aY?dj5-x$h% zvX5-84A_^6?PZ_kJY~A7E zE@{i(>p(Abco`nZfXUTQeOUa?@qMiL2Q6({Y6xkA;~T>W66dD${KTB^=I=uhHh%^n zT9kbhXp-lXNzs;{tHgXTIQ2ScXbIfSnG~T`s&N?MAaeI766i2jVy4)8eoIr|BrR{< z6--Ye$COJo(Uz8GcPDp{>a&Ppb`RTixB7uGhz8VU-oGu699w-ob-&mX;X&%|wJ#85 zgE;6gh}x%_Vr)7N9$?t4P((K6=qa%+LjqA}<3&89?|xR{#4>DN}u^*Mcsoa?*l6%(o-g2O0 zbvW66hh=h6U)k4nX5mdhom%-`bmIUijXUuIAuPX1bxA7DSwXhRaQ}u>ZqzTL6Bm<4_mFXxrv4nx9#%kaFE{{rB-ur|J-fnnPCc0T1>Q8ajoAWP(Rqa0pC#|fxfU7EmAdWWtkbqXo0O_s1rVP~N$ zfP9~NrmJ-5hW4spPE>~GTr+bGmSIntJREg#iA%<5Y$lefzEU~sd?)+rZk1)yP3d)9 zDug|X!^|_7fR9DJIfs_sZ)}{5YRPUAKDx^AGu2Vyt0~sqB3ey2B1;#>y+zr6EXzSn z1zJ{m33)t?($gwf$H0Wn#phU0zF)fgW-Hre1>f6^7DM0;DtMp$37nfxl~}tEB%umshR7HAo=PmvaNS}VD#U^rvF(Tw7x^AOeL~X1GTc54_qKpU{ zyr3o-leejjNHVkicG*>ZadgTP;3yFtI(}Sh*?~zxn=TVm!+28IU|Eg~sh_MaSQTq! zvqkrFJU2ZO6j9Zcw~Uct<+H-=qJgg;PmwKVuZ8ou|d?u!LcD?bzy72g9^n|r8ezsCU>IADj zqEl*xdrq%k{9s$~m0hG!T>9n2i)0H}JO9_`;T0kn`s#=0iH(2FgBN2kIEPFjF=n4) zmlSg61l0|8&)2p>yFG1@{dJb_{+A^qZYR&WZ z{U&ke9-UJYkwfPlE2e#J@WQ9}{E+3G4RP^9nqRGdfSvKiR#Y>!(|KHVaKbRBfW1o< zhFsMp2)-A*w3Zx!=gOnQuIcX|7oH9pm#7p5Ro2o}5&<^{$m6=isG5ugb;fc8JHu%k zSx-2-_q2yJE2XyCBJ)VHS@y(lH6CQ}u}{stZVOWvEBU6CcaI@oBYFQ zZ>dsK?oytTW2L&KT%^`r^Lb|NV)C`6p<~wu2qts)_^A}{wfoF2->HtDlm~B4u}5wq zj5vG)bA>u~%zCQ1tkp-9P$Mm*HeR(tEmMUhX_WKTdSJ zX5N3EicjAm<@xFrn4_e<1OC9r=m@WGEt7m~Ndal=#_~1{^QMmPJg5rkJSXX1kK|eh z)#9XG_v;gsJPvcS(hhv0HwZERv5iETkJ;{&@arX*xNnpC)K{e{F;^ z5g8ocA_Y(pc%Q>EB74PA!>@G08NqVgY8+ z+)_EFO=-ms&XN<#&^*RUk^`!&9KA9V*(Vnb7q%PA?(VPiV+qw1*`b<^CL)7(@6cqa-!0;^}rX68^8IvYHe&e*X>QA>GUOiydLmG5~ty+J4 zHf92zL;*#=9Rsg^sK&L#Vl&3FGUpQE-f1CEl5ihL+9bMPyEo3(59=}g4uaQHU5wV& z>oMfC%|<1YFfc+cb`p@uc)tc)H%PN;m6hMVQR!GNY22066COif#y{11??YYu5sjUr z5F+obc5s`&hH*;Y+gCsMHp~mtKnwuFm#kdZfpk8-VcE{ z4~=;>v&?1N8GHu=>agK!TH8)S+>q|%-_}VL7^F8kP2l^;B-%~&DT0rj`u5@q` zA@_GSnE|E`wUbgs-ylg9J;rMHCexhA1CLWyo=W5H^w3%U;P(hKwlEZq#80$v;;eV@ z0e<#T&p>r`FrtqYStMQsJJPJ8hyZxO0RF)au)ir{ly=5%^5(MyNSD}cH^>AM6{o~e z6J;G>1$@{6B+--+6nGKVMM$gAJ-x(%(+Qi=64s|Sg0?%@yu~rxRHJ-`Ie@?{E}^s1 z5{_tvET4!J!q6@uP1V^GWRnZhno7j}ko01}WXQoe{3{pREol)~#~)Q+<9br|AK25<@k}WjPyAetoRC( zi`}T+ct_y5M1_{4^yp!K{3|i2FYatZ21T<9ASpM`e;v)bI67OKh?<%GD=|>lRYny@ zWa0JPRQI{M*oWP3d46mia>@S zL8@BLbIeX!zHx=;wrox1D~)_q^pKzm4_z_6)*){oYZ9VjN^8aVD7^wqL!M z2JUO(42=&1X|X2_)2)pbQA-@OGu%e0W=3aR2V89!$7XmL=$}JzU`uv-Y*I$PsTZXt zImer}Y3VLxp!7@Z6Q5wOx#0F20Hvz~HjZmCk(nDCr2*<%0RN{LvzY)ncySl!UCe=% z9>f&t9*X;PrSd7&u0hBF>q^{FM5yECN!odeOxrPc1)bv*%w_wm{K8L;YgoNKUnouQ zs(q9Ph9YbAsX zIv8ovkM@{zOtX`sx5eXuH~Qvr!NyQ%NGUEdXBjCvrI2!QDhv-5yVJCUV-`;KW*%O0 zqs%1-am#aiU2Uh5x+SGH={=<*Iu~7Q#N{j%wVYk|B9V&Z_F5x7U_wg%0uTM@(!IZd zcP_*QS8GLx%MKhKf?%?gmdP}!P%Q!gA5~!>)maR?!(_lxP-nSSVLjtXZEStlWBm{g zt#tImq*0@UJo`S!Bt>23tihRpPbQ2t^ZQbBkeHZ@bN|$&JxxPCbC#!uL&)V#SEtFh z7H2s|`Su$1WVl!ULf0B6#Ru7Sct9hc1&zC5zETRu$0SE&i5*wgpRR8y4};jxr#p$x z#u!A@?q;NAkX19ii+IEV=5o#Dl9d(AXUx+5O>D47rlU#`aY+ZG083NHYOW`i&__KM zqj%c(_gaQMl8u*uwdpC2Buc4NnVC8CFP1ixHrjVo zJww-POy?Ihj-ec_(qG#R}`m!mvAv(#Moku+D&sCb4^1T^*cafCdjT(N1S2U;Jz#Q3AB zYHgA4io{=MGd><>$LGCXUSp~d-VGRLh1E}wGsaOkSRSaE);aFT8`e)g+mC8xCv(($ z9?r&RFocoR!tRKNa`GIE&2hR>oHtU?iL(QyD^AG|&guCIzn^f&sWMc3$R4O_uNr^vE~ggdnk5!q#j&I^Rqr=5xk8(M zF*eFVZ?~`IoM9Ai@g!QXv4V0aMiPM+?&u^xZYN$)wM_9h06AvfJ*KUT7fIdvd1)o* zbhEE&a9h;1l*l;-`uDu}60axwrh1N}Y7_SCaUBx!X6Z>ZrKfV(M|7d8>$Qlfvz@#i zIx%xWsuIO3HOqMW!}W!~SQgkSRaW+Op*$i&bT-U?;mgus)0`T2 zI&OcjFEBn4>zAJ}*quyJ$_&|N*~ zRyi`@O8hOuoMktU5SSKL@U0tpD*@!tbE8sMqs6h6t%(71!!lh@7MwvSRa+FVFf20_ z)cjrTIGG&>Lf#a}J#}u<9Hy_So-pRNMp7(Ay!tBoeFf0iGgR*G~HME5`G9! zCS5^BsNKW`znU{ov;`w8IOz6+DPSxdd~FSG^|XWn^Gi*=2O0o18H!nhVNr@%hH*~G zjiBc_CucII9&+vUsKO1%d5U}-IiV-=Sn#fZ68MxAbsyN`M8*l)|EAsr8^~Wq-W6pi zv?Tc?1qQWGjx3`v04JEXP_|r7r;;o?H)cbYjoOIzsmgwlRAhNj`)xb9(?y*FT90M! zRHsK$VeVBnn4(>Tp00=0X|(+;(`zi@HI_Q8WOUlwDy86-_v2>}q9}+k)MBUZq5Fc1_Pn!!B&SuS&OS z%S-}j*QrDgFY!`!Hx=eK(0pma2wU3j372Nxe966k?9)l4#+ho6wfixG8UhS3&zO7b ztbD@u_ewe7FeGDx)v6%g$en<0Z7O%Db z20ON^&W}W;9gBFnh>5k5v#=HXDls1H>g}10s+-QJ%}+|2bsLap=%6HHxV>=AG9E=9 zTw~Nxy-l{fsQ5sGjijQ;IOII-TR^Oiro89#!6)DEe z^s|OqaaZ&5EP8iTLjBBC(;1UV=1x=_&h07<=1j+xCC;ESj7{|PiJmZ9(>zvyz~AyC znZ$Kv;2DQdZJas;a*q+__T@}rKcZ}?Ahy_72ICXux=gh(k~m|{Ej*+7Y2Vx>?Pjp^Am|p z49aI7FH=c!uBRpGQAr@VMDcmb{e&`O(8fqtik1n0ACZ^A6qJ(LZfSQ=BzrK0Prym@ z30a@SF48i;8Ey3FDn@^x-L-PaQFGwFBloElr6d_+ zy(7nLt&V4HE?#&37vVx#|FXs4mw%cxK;Jgs`HQ}XU+%tDtu>em5tg^p#}6M?zsPH( z*MIf7&siuSI6-gzs{aK`@sHyC|D62)a4bKUbc}Hv;2jc$uoGqm*=TKrq~^Fn>{%h4 zU@(+VY>XRO!3aG_(o_B}gtj2eNSdyJc7I(wU5i&5>ah~j`82$myq?~tbU%-`+5NyY zdJzuevMkpmoJwqxq82`e+g#YB1Zv+_ zwO#9LEb7>vXT-}LK>X*t&+(92{?B=zM|Gk$8>9yuSq9hKH3Q^l!vn*u=H@4zQ9m#h z?1hJZ*tU#!S7M2=O4x{AF&WpKcm0$%j%`w=H8D43Y|i1|gJiGX0SA7bJyn@t=`a_D z?Y2Z9Fb?J&b$4f(CUM@5puF$B#6-l}gEo!O-p~1&2KU|tp$Mejyyb^{c=9G<7<^BC zqJCL>5tZ&30mT=C@a8E~r9ddcphx6ZR&`L*AQ!HSB$xr85m2RhB_V2Y5Y@S_7)3;A zNi}BAjGwpaXtPQ4hIStwAurnQd^u%JZ*98FoF3w_t0}edC(58fG&gW8334-*oQmuw zDZlWi2ASx~z55<-9jXk|27HsOz$`DdL-{1g1&@a)Zl}ins`W}TeQ85$H!~%$L}e}} zS14EL=GVB%yO5nC-93yCyb0fKLtO@b$UNDm$g1=2P8^kAodl-RWHZW6B)1cveY-nC za=ULS$&{0-#4n*pwK5UjiCWsXHg9q54=;tP8gN8tm$f~ek_*=>#~xaf3&||3p-cSz zghjMqD6K<}uT)L96h$&v0`Ciiq^NviwY zgIwlDNgRmG9O^}%q;jotj->*hpXF`fZgTfEp&AAXR+IX zOEAnq?-9X{n}hQfl~5)T?=@_anZG9eK&`NOouh+7A4_#l^2{-q>zWTX1Cj)AL=qC5 zw?9ChNPfngaK3$tUEsF*mj84epMOV|e`N9D0doF+r?^M`+E)yll+VxL9TV@_xyKTV z^0jjzLYL|TO*6^Wq)sHqlg~43wiR;|$K5^PMc;)j`deKAS`|ggZN!wiceh12Ha#F~}X&&ah6f*Tl zEk1N3LuK9~gBJAi=R9(k12GVo5|!Tz;o*2tdj>XV`_P7v)loq}vQD{bkb(%)fxz|?3M+zV#zIA{OVM<%RFoe+eaNJ@4p{un&g!S zo(4*IsV-m2?*XQ;-A4vF9~c2E4c5s;H0}Kb7Mh-F0pI#NuHm#jMai9uFc@&_mqWVK zAEg%?ZcZ4=f3HQxmv5BL*zM_NnVmQWQeK4;*|&*f8$&HGoI#>o@A^Omv1rB(g?gIs z#Rvku?zhn0vJKymLVOr$dEGJtV-e@h=iGWy^+t8ObB_3Ybk+ILn*`rZ?KffO(Dc=U z32dJ$A--pVsJtSG6tCAkF@Gkf()>dZ06L5gXvBb4ZQ)5y6Y>M8#el=hh@U5UT-jsH z8y1XRP(O4VmSuEC;DLI`tt~zMn^Z`L+Gu(4`4+TW1)$!9T4G$8_x;~gl zVSInG&N$t$8bW!FrE4l{_VB%}LB@e3{Pkq~a`t#EZ)OefNPsZCj&?UWi3bil(_o2l zd|K=gt~`y}1mrILeV}y8R#sDcXMg$boRe`>IS5noX{yLHIH5>w#xdJ8!2G~l=9#aI z?yR5Pt4OHuM|07{eSvQ3JR66I=4Im+EOHsWS`>>;iI^xWKrHKhpH(&0=^RBm==(*7I9XB8d8+dszl+3SY*Hguv0uDL}oTYS7IH zeQ-4~oa*^imhd?kIX6@_SEqXTu|BA57%d&8s-mi`K?PY9Xv?+8Cmdx9H?;lOwl%th zSwqm0@%)!j*)a38XFIVg#w_Cj7I^U6t)=V)6|+p?(~3{|EJe}epfWk4C`Et6so2It z^U;R!oI-HAr^u0>n#PjwK8O4dW$L$p9|=?glgl=qB)Jt<56bN3-~(XYA}+HM*K+0~ z$h^^s-OFpLdvl4cQl{6P5jm>S z7I_eDssL?j;a+eM-O%5vLVSWpQ}I~6*kRV^B3WJYzM__fy2X7WZL$FVJZ$6;cESck z37<7xa{24cL131!_eVa!I>4l!DswkCyII140*!(rt|Sfbz+y7JV!^L$`RtbV9^VM~ z1}4Se#WKtl7WnoAXz_P>KBy8hktEsUy^BJ6x`ViXvD|D_?4QQCV@henDwGh5%7!=| zQIZk2pGEF}7v)eSPi!Yog!zV}fIRb8C#et#8%=vp{(%SAGjMgdaC#{u$zAm=)&$0r zIk1HVZHBQa=v8c`hHE88xUb_cf;DGFV2S~+j|tYnSF{8?0(glIGFUXe<$;uJC!mLPKg? zlK2Lr#y~l;vdw27{mV~;2jTN?MNe7y{Li2nkPp4nsE|lM=LbVN`}O8~=d;_j&vW;m z`|IvsUjaMn1eOYwd2*t>Jb)2eZnP_dMj8?_tGmj*aqzj zq%b77i8!1q+;Qa@7E0O%7*uFczM@3YnM-S}%=}hy=jrK1kt_OYKsB=%_J)#Sp%ueF z^x+twt*|suwcH_2L4YQY>tx1~QI5JGiz6PI2~r#w!tQq&`i^W0i*;9%1D%s?!9g zm=d_>rJqzq%-38_niQt)Py1vtVFa6;y@LEQLurR znDjm0gpBzVh#5Ceac*9cNDJ=7O6?!cqN9W~L=m6ZciF7m4qy-0@%mX3DBN&1qN+2D zR*_Z})_-G4Rf61v!6-xJt~G>Of;FWYioM9(Q5Y_DI{KXDxrPb8usx-Is)rRN426+pYVq!n} zV1Vv`2BYAlwExPv+_~moHlX=CoJeM@3w_<`D~YtOb0Z{>8HYq=>wdGvqHMIc2!aGK zL4qJR-?N6B6DSG{vU+Pmb)|5u#9l~vuOeApLtd2SOPLsK=Pe(C&#(yBf|*R4JL%20 zb8DDNU$)3}wwa^<`opTK8dN+joe-IFegF_Kran~BT_m{Ip7>|1Pkfh}guEKGnHB8U zGGM%L<>)`97-_Fs)dD7FBiDIOq#v{4*_Zc!ZJmyO1DE3fZXT|X#^rEgj8Lap$xV@U z?B0F%aOFkeH7=5zPRYuOW{>!_0vITdRuey6l|-r%sZ&WP(gH_w#n|+RvL$;dIH=eW zhIJM!kyoKD{*j`s(_|e=t}ZG`YkGJAL9if-)SSFLg!EO&Cg&+B(Z$-vA&L85jcMI~ znt}k;Y;q>i7nX*9k6_Z|&_bf+&r(*d&Fu~xLC>H%Y!CY7M<#Nvv(uQAQ>)D}H($s~ zy>-Wz4Z5qxnx9)Yb*5zSIhSEtYFw?Dn(KcFiYPkgv~I1=a~0@Ba-$-MrDX6JWo@6a zwAdynr&>>cZ&{ZX(UNy5-4GAt!{4gjFpK*5C|2BeF??6pulO1ydTd49ed-yb)g_?6 ze5xyat(!m9`7?itTOnl@wIfJ8rJh-(GpgHf#uDE8&4!%!C;i8jFp4g|_U?#W|I|2Z zIM_E%ftVEgAezmhdW@=Uz19ItS;GZ%S`1IlaSo}E*X#CxgfI!*YEcke1vv|De z3Ooe8f_MRMtlG9LIjNe#By;Bt(Gxx`KYhrD(c(g6naRlZpS2y8{j9sL;2Kt2R%Es- zbZu8qG%Kr)#*pHr!V?^eujEq;0#Rx=3%md@>Nbifl+=2#RCGf?R$w4qgbw>=ggk}= zY>gxA9_xj>#6?=$4w4?{cDMtF51q-DM(T5>LyJ}6}KWu-*Sc_S|Y=5$$3w>>o#lMz>+gYQ<4dec5X#V1Qb$1Qg zwhY5Ee-T0AJz}+A6Tz2b(BVhuQNCzhHtct?a&15t8M?|=#f}fy4ut*i)24fbT5z=e zY5@X#<6w9jE?$*d{k$ae?N|CR@0gQYEH|m#sQsRpd#x;=(nD`C2-Nk8KlTq{O2)K! zg+U6?`OJ54b+U(jkv^{&b{;LO$nJHKFH6E*CuC(O>3JEV0UXNe;;KBbtb;a)gAl#P zYY$c{Uo-JuXSsadS`UU$Y4^?UWJu^F%phycS|}wn9e`pT2`}~FPe8>}PomxiB>Z;tX>#_Rz3q!<|ychEd4E7JT5>y1ITJEApa)|5^SW-N+lW&7C(6 zJ`+#sj7MV(~k>c1#ewi(VJ?WFU^wsV*Q(ux| zBt@hN1>y;)M-NGaNxEt{%u47i!fg=hdRXrmGl`kMb+<@){+0=iS{`lw6^bkgxETINM61*Ol3LQ*f-31>*r5S9u*Q{JQF!}}%xefF-uS1zoF&Uc9D#h1g>S)q5%hDA_2 zbw^M#hqyqaf>E=CDh94sUlnC%&Q8HA7T2@1q)eF~UB#7D^b3a*^F_vgS%uZI%?PA8 z>V>7Ky@r$$YEveW;{JZlP)JQoK;1-himWAVD3TjQf$|wS;FW)xrGx(=c!f&!)|*MC zB3cS$l;G}wg$cV8p8JB0zzcCF9^1setR5|MGQE5`1Y0%$0xu-jSEXYHuNfPZ<|%l@ z(55QLzm`_iN;1AlPw!IdX3dx=l$<0bbLFO9V?^+fQ{K$|zT(MS_~3!DIx1@0X(?HR z#KC-q9j3+c@}EPKdvEVuED@iypsT~ebYS!jnk0_LRH|@JhAQ}EovjTk)xtUlxWCTwfX1#S(&5i9C<$du^^ltE`CtekxplgmVV*_9{`y z`hp(5Bp=f72-h`mbc8nWyBpEd`)b$qQ}#q1Nh}Yw!RmWQJawv^o=)LsyhHf5)cQzy zMuO&$Fv7hPEh$>ppwi<;V%m=d$c|}$ZSCeqFy;unHS-yU8e92D#ZF3`afU}9CwVj!niSfT&3Mc%B z{4{8jvR-=hMlk^BX9!yG#aj#h^T#{~Gy)U`A!JQK0X&dL1#e)uZ?O_BzDOEAvQ6jkpDW1zxYZB#R**ZKNNp=ory z{tLJ^_yt^(VisJWih$|#Sf4uw4cd^9ns6g8chufw^7YFavFp6g13CM9F?@U;esHiF@L54wwJrE3O`Q~?|^d(Yd_rx$gAvzlp!cb$Q zexL&`#G|tHO6B!OONuCiza?9NvDdA5z6cnfae}gA`k`NljxvF;|Ag311g;(#Da?{? zvatjG?ue&#pPwUvZ5Rd$kp;isd4(xzwJW!B(+h%C*9Hk>>4x_7b;+McTSgxsc6_a) zf?+040~RPsrGdpEL<3vn7>Sb98BrotQ7Ts>bXYC+hm>5)G=+9piB^ywMK^!OQr64Sl9F+43Zxr2NoaxG3duUB{$pj+elIguP@ z>#c216D2WWxR~JsGA5kXY0Tet@)}K-twe<#gGxE=JTebKj#awb6FcZ{U~D{da@o%3 z=o0#K(N+s@pK{iQT`^xu1F4>r?=Ot^*9FU*$v=o=b6bg7<@ybg0%Y-Xtrm<=_PUe~papv6hS0Vf(?=(Qt0+nkt60LwOG2W|AG za2`FIBiKVA4ZPSf`Ba_W}k`0>Rx5(7`mu!OjBJc zKPrL-70Kh*pbzD>vI(o?4>%$dkCH==lH}g@ICR~gwzTBt@s=O>5S?KefFmbWsXP

    XEKGDk9g0hg_Rp+z8 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 z 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

    adNOpeva)dgKR+7&LkmT3{N?LSZ*1aZ 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%0A!(8Mw+IoN~fb*#K~*L#3b4yB%xQbEXX~=Dng%L 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�}G1nh)1r>EW{c%!sMsN#$p-lgS~F<^`# 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